All findings · Lucene finding · LFV-121 · bitvectors-float-field-closes-indexwriter

HnswBitVectorsFormat: one float vector document closes the IndexWriter and rolls back everything since the last commit

Since 9.11.0, adding a float vector field to the opt-in, byte-only HnswBitVectorsFormat throws the expected IllegalArgumentException, but the same exception also closes the IndexWriter: the next addDocument or commit throws AlreadyClosedException, and every document since the last commit is lost (in the example below, the index ends with 1 document instead of the expected 3).

Summary

Priority
Low, rank 671Moved down from Medium: one float vector sent to the opt-in, experimental HnswBitVectorsFormat closes the IndexWriter and rolls back every thread's uncommitted docs, severe but loud, caused by an application programming error, and the committed index stays intact.
Impact
crash
Since
9.11.0 (#13288, backport #13316), the release that added the formatintroduced 2024-04-17 · about 2 years
Affected
9.11.0 to 10.6.0 (19 releases), and main
Status
documented
Found by
code reading of IndexingChain's aborting-exception handling; confirmed by executed tests

AI-assisted page: this finding was first signalled by code reading or testing 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

HnswBitVectorsFormat indexes byte vectors only and rejects float vectors with IllegalArgumentException. The indexing chain treats some failures as "aborting": the in-memory segment may be broken, so the writer drops it and closes itself. This rejection is raised inside such an aborting step.

Background: a rejected document vs an aborting exception

  1. IndexWriter buffers new documents in memory until a flush or commit; a near-real-time (NRT) reader can already see them. Only commit() makes them durable.
  2. A bad document normally gets an IllegalArgumentException from addDocument: only that document is dropped and the writer carries on. A vector over the format's dimension limit is rejected this way.
  3. Some failures are "aborting": they can strike while segment files are being written, so Lucene discards the whole in-memory segment and closes the writer as a tragic event, back to the last commit.
  4. The first time a field appears in a segment, the indexing chain registers the field and then asks the vectors format for a per-field writer (addField). Any exception from that call is treated as aborting.
Diagram of the bug on Lucene main 645d4fe14a20: addDocument with a 2-dimension float vector in a new field routed to HnswBitVectorsFormat. IndexingChain checks the dimension limit, registers the field, then calls the vectors writer's addField inside an aborting catch. The format throws IllegalArgumentException 'only supports BYTE encoding'; the segment is aborted, the IndexWriter closes, and only the last commit survives: 1 document instead of 3.
How one float vector document goes from an expected rejection to a closed writer: the field is registered first, the bit format rejects it inside the aborting addField step, and the writer rolls back to the last commit. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You route fields named bits* to HnswBitVectorsFormat, which indexes byte vectors only. You commit doc 1, add doc 2 (an NRT reader now sees 2 documents), then add doc 3 with KnnFloatVectorField("bits_f", {1, 2}), a field the index has not seen yet.
  2. You expect addDocument to reject doc 3 and nothing else. The format's own test expects exactly that exception, and a 1025-dimension vector in the same field (over the format's 1024 limit) is rejected that way: doc 4 and a commit then leave 3 documents.
  3. You get the expected IllegalArgumentException, but it is also the writer's tragic exception: isOpen() is false, doc 4 throws AlreadyClosedException, and the index holds 1 document. Doc 2, already visible to NRT search, is gone. CheckIndex is clean.
  4. Why: the format rejects the field in addField, which IndexingChain calls only after it has registered the field, inside a catch (Throwable) that treats every failure as aborting. The dimension limit is checked before registration, so it does not abort.
  5. It needs a float (on main also float16) field that is not yet registered as BYTE; a field already registered as BYTE gets an ordinary rejection. Affected: all 19 releases from 9.11.0 to 10.6.0, and main.

Who is affected

Only applications that opt in to HnswBitVectorsFormat (lucene-codecs) and send it a float vector by mistake, on a field not yet registered as BYTE. One such document closes the shared writer and discards every caller's uncommitted documents, including NRT-visible ones; re-sending it closes the next writer too. The committed index stays intact.

Mechanism, terms and numbers536 words

HnswBitVectorsFormat (lucene-codecs, @lucene.experimental) indexes byte vectors only. Its writer rejects any other encoding in addField with IllegalArgumentException("HnswBitVectorsFormat only supports BYTE encoding"). IndexingChain#initializeFieldInfo calls addField the first time a vector field appears in a segment, after it has registered the field's FieldInfo, inside catch (Throwable th) { onAbortingException(th); throw th; }. So the per-document rejection is also recorded as an aborting exception: the in-memory segment is aborted, the exception becomes the writer's tragic exception, and IndexWriter rolls back to the last commit and closes itself. The caller receives the IllegalArgumentException it expected; the next addDocument or commit throws AlreadyClosedException. Buffered documents, documents already visible to near-real-time search and documents that other threads added successfully are all lost, and a fresh writer closes again if the application re-sends the document. The committed index stays intact and CheckIndex-clean.

The same format's other per-field limit, getMaxDimensions, is checked before the field is registered and is an ordinary rejection; the format's own test expects only the exception. Since 9.11.0, in all 19 releases that ship the format, and on main. A small pre-registration hook on KnnVectorsFormat fixes it and passes the codecs module's tests.

The code (main 645d4fe14a20)

  • Path. IndexWriter.addDocument (IndexWriter.java:1492) -> updateDocument (:1912-1915, call at :1913) -> the private updateDocuments (:1625-1641) -> DocumentsWriter.updateDocuments (DocumentsWriter.java:428) -> DocumentsWriterPerThread.updateDocuments (DocumentsWriterPerThread.java:291) -> IndexingChain.processDocument (:641) -> initAndValidateFields (:685-699): the field is new in this segment, so initializeFieldInfo runs (:693, :1372).
  • Before registration, not aborting. validateMaxVectorDimension(..., getMaxDimensions(pf.fieldName)) (IndexingChain.java:1386-1391, plain throw at :1625).
  • Registration. fieldInfos.add(new FieldInfo(...)) (:1392-1413), which also checks the schema against the global field numbers (a field already registered as BYTE fails here, not aborting), then pf.setFieldInfo(fi) (:1414).
  • The aborting step. try { pf.knnFieldVectorsWriter = vectorValuesConsumer.addField(fi); } catch (Throwable th) { onAbortingException(th); throw th; } (:1445-1451). VectorValuesConsumer.addField (:68-71) creates the segment's vectors writer on first use (:52-66); PerFieldKnnVectorsFormat$FieldsWriter.addField (:118-121) picks the field's format, creates its writer on first use and puts the per-field attributes on the registered FieldInfo (:147-185). For the bit format, fieldsWriter (HnswBitVectorsFormat.java:139-148) builds the HNSW and flat writers, which open their output files.
  • The throw. FlatBitVectorsWriter#addField rejects any non-BYTE encoding before it delegates (HnswBitVectorsFormat.java:193-198, throw at :195). Executed stack: FlatBitVectorsWriter.addField(:195) <- PerFieldKnnVectorsFormat$FieldsWriter.addField(:120) <- VectorValuesConsumer.addField(:70) <- IndexingChain.initializeFieldInfo(:1447) <- initAndValidateFields(:693).
  • The close. IndexingChain.onAbortingException (:176-180) records it in the DWPT (DocumentsWriterPerThread.java:56-60); maybeAbort("updateDocuments", ...) (:310, :594-605) aborts the DWPT and fires onTragicEvent (IndexWriter.java:6068-6084); IndexWriter.updateDocuments' catch (:1632-1640) calls maybeCloseOnTragicEvent (:6098-6106) -> rollbackInternal(), which closes the writer without committing (rollback() javadoc, :2566-2571). The IllegalArgumentException is rethrown to the caller.

The aborting catch is defensible for codec failures that may already have changed state: by then the field is registered, the segment's vectors writer may exist with open files, and the FieldInfo carries per-field attributes. The defect is that a deterministic input check, which needs only the field name and its encoding, sits inside that catch instead of next to the dimension check.

Exposure and likelihood321 words
  • Who is hit. Applications on 9.11.0 or later that configure HnswBitVectorsFormat, codec-wide or per field through PerFieldKnnVectorsFormat, and send it a KnnFloatVectorField (on main also a KnnFloat16VectorField) whose field name is not yet registered as BYTE: a new field, or a field committed as FLOAT32 under another format and routed to the bit format in a later session. The format is opt-in (lucene-codecs, not the default codec) and @lucene.experimental. Any index size; one document.
  • What goes wrong. Every later indexing or commit call on the writer throws AlreadyClosedException until the application opens a new one (getTragicException(), isOpen(), close() and rollback() still answer), and every document since the last commit is lost: buffered documents, documents an NRT reader already showed, and a document that a second thread had added successfully (measured; in-flight documents of a concurrent thread were not measured). The caller's IllegalArgumentException looks like an ordinary per-document rejection, so a caller that catches it and moves on finds out only on its next indexing or commit call.
  • Repeats. The rolled-back segment takes the field registration with it, so re-sending the same document to a fresh writer closes that writer too.
  • Not affected. The committed index stays intact and CheckIndex-clean; nothing is exposed. A field already registered as BYTE (earlier in the session or in a committed segment) gets an ordinary schema rejection and the writer stays open.
  • How likely. Low: it takes the opt-in, experimental format plus a float field sent to it by mistake, and that mistake usually shows on the first such document in testing. Elasticsearch ships its own bit-vector format; no use of Lucene's class was found in Solr, OpenSearch or the OpenSearch k-NN plugin (code search of default branches, not exhaustive).
  • Workaround. Send the format only KnnByteVectorField values (check the element type before addDocument). Once the field is committed as BYTE, a float document is an ordinary rejection.

Minimal repro

One JUnit test in the codecs module's bitvectors test package, public API only (Lucene plus the test framework's LuceneTestCase and TestUtil): one 2-dimension float vector, the codec pinned to HnswBitVectorsFormat, ByteBuffersDirectory, one thread, no random() in the test body. It fails with assertions on and off and passes with the fix, so it doubles as the regression test.

TestBitVectorsRejectedFloatField.java (lucene/codecs, package org.apache.lucene.codecs.bitvectors, 1 test)42 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.codecs.bitvectors;

import java.util.List;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
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 TestBitVectorsRejectedFloatField extends LuceneTestCase {

  public void testRejectedFloatVectorDoesNotCloseWriter() throws Exception {
    IndexWriterConfig iwc = new IndexWriterConfig();
    iwc.setCodec(TestUtil.alwaysKnnVectorsFormat(new HnswBitVectorsFormat()));
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      expectThrows(
          IllegalArgumentException.class,
          () -> w.addDocument(List.of(new KnnFloatVectorField("v", new float[] {1, 2}))));
      // expected: no tragic exception, writer stays open; actual: the IAE is tragic, writer closed
      assertNull(w.getTragicException());
    }
  }
}
Command
cp $REPRO/TestBitVectorsRejectedFloatField.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/
cd $LUCENE && ./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.bitvectors.TestBitVectorsRejectedFloatField -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
Expected
addDocument throws IllegalArgumentException and getTragicException() stays null: only that document is rejected and the writer stays open.
Observed
getTragicException() is that same IllegalArgumentException: HnswBitVectorsFormat only supports BYTE encoding: the writer has closed itself.

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

More files, environment, full output385 words

Copy the test into lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/ of a Lucene checkout and run the command below. TestUtil.alwaysKnnVectorsFormat sends every vector field to the bit format; the config is the default one. The test asserts the root effect only: after the expected rejection, getTragicException() must be null. The closed writer and the rollback follow from it (the index ends with 1 document instead of the expected 3, an NRT-visible document lost, in the probe numbers above).

Under gradle it fails with -Ptests.asserts=true and false on main and passes with the fix; on the standalone runner it fails on main (seeds DEADBEEF and 5EED1234), 10.5.2 and 10.6.0, with assertions on and off. TestUtil.alwaysKnnVectorsFormat is missing on 9.11.x, 9.12.x and 10.0.0; there a variant with a FilterCodec over the default codec, same body and assertion, fails the same way on 9.11.0, 9.12.3 and 10.0.0 (9.11.1 ran only the study's own portable tests, which fail too).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.5.2 and 10.6.0 release jars, and in release-portable form on 9.11.0, 9.11.1, 9.12.3 and 10.0.0; 9.10.0 has no such format
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on)
Command (environment)
cp $REPRO/TestBitVectorsRejectedFloatField.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/
cd $LUCENE && ./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.bitvectors.TestBitVectorsRejectedFloatField -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways

Observed output

# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with false)
TestBitVectorsRejectedFloatField > testRejectedFloatVectorDoesNotCloseWriter FAILED
    java.lang.AssertionError: expected null, but was:<java.lang.IllegalArgumentException: HnswBitVectorsFormat only supports BYTE encoding>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B8FD3E78AD3F65BB]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotNull(Assert.java:756)
        at org.junit.Assert.assertNull(Assert.java:738)
        at org.junit.Assert.assertNull(Assert.java:748)
        at org.apache.lucene.codecs.bitvectors.TestBitVectorsRejectedFloatField.testRejectedFloatVectorDoesNotCloseWriter(TestBitVectorsRejectedFloatField.java:39)

# probe without the test framework, abridged (main 645d4fe14a20, -ea and -da; the same on 10.6.0, 10.5.2 and 9.11.0 with -da)
A (claim) float vector {1, 2} in a new field 'bits_f' routed to HnswBitVectorsFormat
    doc 3 addDocument threw: IllegalArgumentException: HnswBitVectorsFormat only supports BYTE encoding
    getTragicException(): IllegalArgumentException: HnswBitVectorsFormat only supports BYTE encoding
    isOpen() after: false
    add doc 4 + commit: AlreadyClosedException
    NRT numDocs before doc 3: 2
    committed numDocs at the end: 1
    CheckIndex clean: true
B (control) float vector of 1025 dims in the same new field 'bits_f' (getMaxDimensions = 1024)
    doc 3 addDocument threw: IllegalArgumentException: Field [bits_f] vector's dimensions must be <= [1024]; got 1025
    getTragicException(): null
    isOpen() after: true
    add doc 4 + commit: ok
    committed numDocs at the end: 3

Why it looks unintended

  • The format's own test, testFloatVectorFails, expects only an IllegalArgumentException from addDocument.
  • The same format's dimension limit is checked before the field is registered, a placement maintainers chose in #12436, and leaves the writer open.
  • LUCENE-5662 fixed this pattern as a bug; Robert Muir's reason: an aborting input check makes you "lose other unrelated documents in IndexWriter's buffer".
The full argument356 words
  • The format's test. TestHnswBitVectorsFormat#testFloatVectorFails (TestHnswBitVectorsFormat.java:54-63) adds a 4-dimension KnnFloatVectorField through IndexWriter.addDocument and expects an IllegalArgumentException. It checks nothing about the writer, and its message check at :61 is a no-op (contains is not asserted).
  • #12436 (2023). jpountz first suggested a dimension check inside KnnVectorsWriter#addField; mayya-sharipova objected that by then the FieldInfo is already in the global FieldInfos (review comment r1275409313); jpountz then liked "retaining the property that the field info doesn't even get created if its number of dimensions is above the limit" (r1275939419), and uschindler welcomed that the check runs in addDocument() rather than while indexing or flushing. That thread named the poisoned-FieldInfo hazard, not the writer closing, and predates the bit format.
  • Lucene's tests. TestIndexWriterExceptions#testNullStoredField (:1592-1614, "test a null string value doesn't abort the entire segment") asserts getTragicException() is null after an input IllegalArgumentException and that the earlier document survives. BaseKnnVectorsFormatTestCase#testIllegalDimensionTooLarge (:779-795) and TestPerFieldKnnVectorsFormat#testMaxDimensionsPerFieldFormat (:217-244) keep using the writer after a codec's per-field rejection.
  • Precedent. LUCENE-5662 (#6724, Robert Muir): missing null checks in Field aborted segments, "This is bad because you lose other unrelated documents in IndexWriter's buffer"; fixed under "Bug fixes" in 4.8.1 (CHANGES.txt:12384-12385). LUCENE-5611 made doc-values and term-vector setting errors non-aborting. LUCENE-5987 defines an aborting exception as "an exception that would otherwise cause silent data loss" (CHANGES.txt:11577-11579).
  • Nothing documents the close. The IndexWriter class javadoc names "an Error, or disaster strikes during a checkpoint" (IndexWriter.java:162-165; not a complete list); addDocument says that after an Exception "the index will be consistent, but this document may not have been added" (:1465-1466); getTragicException gives "disk full while flushing a new segment" as its example (:6108-6112). No javadoc, comment at the catch, CHANGES or MIGRATE entry says this rejection closes the writer.
  • History. The aborting catch came with #992 (LUCENE-10592, 2022), with no comment about input checks. In the #13288 review the author said the wrapper writer exists because "it validates only BYTE vectors are written" and jimczi replied "Ah thanks, I missed the addField validation": meant as validation; nobody discussed that it aborts.
The devil's-advocate check243 words

The case (from the cross-model reviewer and the study's devil's advocate): a float vector is outside the domain of a byte-only, experimental format, so this is application misconfiguration. On 2024-06-05, asked on #13288 whether the format "was more a prototype and not intended for production use", its author answered yes. IndexingChain marks every Throwable from addField aborting on purpose, and addDocument promises a consistent index, not an open writer; the commit does survive.

Why it fails: the format answers this input with a deliberate IllegalArgumentException that its own test expects from addDocument, so the input is handled, not undefined; the problem is that the rejection also discards unrelated documents, the case LUCENE-5662 fixed as a bug. It does not merely fail loudly: the bad call gets its exception, but the documents discarded with it were added by calls that had already returned successfully, and those calls never report an error. The aborting catch protects against codec failures that may already have changed state, and the same IndexingChain checks the codec's dimension limit before registration: it was placed there in #12436 so that no FieldInfo is created for a rejected field and so that the check runs in addDocument rather than during indexing or flush, and that placement is why that rejection does not abort. @lucene.experimental marks an unstable API, not permission to lose writes. The argument lowers the impact and makes an "improvement" label likely; it does not make the close intended.

Suggested fix

Recommended A pre-registration encoding hook on KnnVectorsFormat, called next to getMaxDimensions

Add a checkVectorEncoding(fieldName, encoding) hook to KnnVectorsFormat (default: accept every encoding), delegate it in PerFieldKnnVectorsFormat, override it in HnswBitVectorsFormat, and call it in IndexingChain next to the dimension check, before the field is registered. The test, the codecs module (694 tests) and core vector suites (807) pass.

// KnnVectorsFormat: the default accepts every encoding
public void checkVectorEncoding(String fieldName, VectorEncoding encoding) {}

// HnswBitVectorsFormat
@Override
public void checkVectorEncoding(String fieldName, VectorEncoding encoding) {
  if (encoding != VectorEncoding.BYTE) {
    throw new IllegalArgumentException("HnswBitVectorsFormat only supports BYTE encoding");
  }
}

// IndexingChain#initializeFieldInfo, right after validateMaxVectorDimension(...)
vectorsFormat.checkVectorEncoding(pf.fieldName, s.vectorEncoding);
All options and trade-offs4 options ยท 713 words

Four options; A was validated with gradle in a fresh scratch worktree at 645d4fe14a20. B and D were not executed; C is the study's root-cause demonstration, not a patch. With A the caller sees the same exception and message in the bug case; only the writer state changes. For a field already registered as BYTE in an earlier segment, the format's message replaces today's schema-consistency message (cannot change field ... from ... BYTE ... to inconsistent ... FLOAT32 ...); both are non-aborting.

A pre-registration encoding hook on KnnVectorsFormat, called next to getMaxDimensions (validated by the checks) (recommended)

4 files, +31 / -3. KnnVectorsFormat gets public void checkVectorEncoding(String fieldName, VectorEncoding encoding) {} with javadoc (the default accepts every encoding, so no other format changes); PerFieldKnnVectorsFormat delegates it to getKnnVectorsFormatForField(fieldName), like getMaxDimensions; HnswBitVectorsFormat overrides it with the same IllegalArgumentException it throws today; IndexingChain#initializeFieldInfo calls it right after validateMaxVectorDimension, before fieldInfos.add. FlatBitVectorsWriter#addField keeps its check as a backstop.

// KnnVectorsFormat: the default accepts every encoding
public void checkVectorEncoding(String fieldName, VectorEncoding encoding) {}

// HnswBitVectorsFormat
@Override
public void checkVectorEncoding(String fieldName, VectorEncoding encoding) {
  if (encoding != VectorEncoding.BYTE) {
    throw new IllegalArgumentException("HnswBitVectorsFormat only supports BYTE encoding");
  }
}

// IndexingChain#initializeFieldInfo, right after validateMaxVectorDimension(...)
vectorsFormat.checkVectorEncoding(pf.fieldName, s.vectorEncoding);

Trade-offs

  • API addition: a new public, non-abstract method on KnnVectorsFormat; backwards compatible and backportable to 10.x, but the maintainers must want it. A boolean supportsVectorEncoding(...) would mirror getMaxDimensions more closely, at the cost of a generic message composed in IndexingChain, which does not know the per-field format's name.
  • Message: in the bug case the exception type and message stay as today. For a field already registered as BYTE in an earlier segment, HnswBitVectorsFormat only supports BYTE encoding replaces today's cannot change field "v" from ... vector encoding=BYTE ... to inconsistent ... vector encoding=FLOAT32 ...; both are non-aborting (fact-check probe, stock and with the fix).
  • Wrappers: a format that wraps another and delegates getMaxDimensions must delegate this too; in Lucene's main sources that is only PerFieldKnnVectorsFormat (AssertingKnnVectorsFormat wraps the default format, which accepts everything).
  • Indexing path only: FlatBitVectorsWriter#mergeOneField has no encoding check, so addIndexes or a merge that brings float segments under the bit format is a separate, untested question.
  • Future formats: open PR #16092's PreconditioningKnnVectorsFormat throws from addField for non-FLOAT32 fields (read from the diff); it could override the same hook.

What the checks validated

Gradle in a fresh scratch worktree at 645d4fe14a20 (JDK 25.0.3, -Ptests.seed=DEADBEEF), 2026-10-30:

  • without the fix the minimal test fails with -Ptests.asserts=true and false; git apply fix.diff succeeds; with it the test passes with assertions on and off;
  • :lucene:codecs:test, the whole module: 27 suites, 694 tests, 78 skipped, 0 failures (TestHnswBitVectorsFormat 8 of 8); core codecs.perfield.*, codecs.lucene99.*, codecs.lucene104.*, TestIndexWriterExceptions*, index.TestKnn*, search.TestKnn*: 24 suites, 807 tests, 34 skipped, 0 failures;
  • checkGoogleJavaFormat (core, codecs), ecjLintMain (core, codecs), ecjLintTest (codecs), forbiddenApisMain (core, codecs) and core renderJavadoc pass;
  • a one-off scratch check (not a repro): after a rejected float32 and a rejected float16 document in new field v, the writer is open, a KnnByteVectorField in v is accepted, and the commit holds 3 documents with v registered as BYTE;
  • after git apply -R the minimal test fails again; the worktree was removed afterwards.

Make the rejection non-aborting at the catch around addField

Catch IllegalArgumentException from addField without calling onAbortingException. Not executed; not recommended.

Trade-offs

By then the field is registered as FLOAT32 in the segment and the global field numbers, carries per-field attributes, and the segment's vectors writer may have opened files; later BYTE documents in that field would fail the schema check for the rest of the session (the hazard named in #12436), and a blanket catch could hide real codec failures.

Hard-code the bit format's check in IndexingChain (root-cause demonstration)

The study's demonstration checks the format name "HnswBitVectorsFormat" next to getMaxDimensions. With it the minimal test passes (assertions on and off) and so do the 8 TestHnswBitVectorsFormat tests.

Trade-offs

Proves the root cause, but core must not know a codecs-module format by name. Not a patch.

Document that a non-BYTE field closes the writer

A javadoc note on the format. Not executed.

Trade-offs

Honest minimum, but it keeps the data loss and contradicts the format's own test.

Downstream note

No downstream product is known to use Lucene's HnswBitVectorsFormat (Elasticsearch ships its own bit-vector format; none was found in Solr or OpenSearch), but an application that builds float vectors from request content for such a field would let one document discard every caller's uncommitted writes.

How it was found

Found by reading how IndexingChain classifies exceptions as aborting or not, while modelling its exception paths, then confirmed with a JUnit on the stock jars. No model artifact or formal tool witnessed it. An independent reproducer, cross-model reviews, a devil's advocate, an adjudication and a tie-breaker confirmed it.

The method in full139 words

Method: code reading, then executed tests (the hunt's non-formal tier).

  • First signal (2026-10-25). A unit studying IndexingChain's schema and exception handling read the aborting / non-aborting classification while building the exception-path part of an Alloy model; no model artifact was produced for it and no formal tool witnessed it. A JUnit on the stock main jars confirmed the close and the rollback.
  • Checks. An independent reproducer with its own test and controls, an executed cross-model works-as-designed review (OpenAI GPT-5.6), an intended-behaviour devil's advocate with 7 scope probes, and an adjudication confirmed it; a follow-up swept the releases, searched for prior reports and measured the scope of the loss; the repro was reduced to one test and one assertion; on 2026-10-30 a tie-breaker re-ran it on main, 10.6.0, 10.5.2 and 9.11.0 with a probe that uses no test framework.

Prior reports, history and checks

Prior reports

No earlier report found (searched 2026-10-26 and again 2026-10-30: GitHub issues and PRs incl. the JIRA archive, mailing lists, downstream trackers, all open PRs and the web). Closest: #12436, where maintainers kept the dimension check before field registration. Open PR #16092 adds a format with the same addField pattern.

Searches4 searches

Related, not this bug: #13288 / #13316 (introduced the check; the review calls it validation and never discusses the writer closing); #992 (LUCENE-10592, added the aborting catch); open #16092 (a new format whose addField throws for non-FLOAT32 fields; read from the diff); #16211 and #14025 (feature requests that mention the format); LUCENE-5987 #7049, LUCENE-5662 #6724, LUCENE-5611 #6673 (precedents for the aborting contract); #15125, #13353, #15540, #16383, #16902, #13076 (related area, different topic). Tracking issue #16937 and its dev@ thread are not prior art; neither mentions LFV-121.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues, PRs and commits incl. the migrated JIRA archive; dev@, java-user@, issues@, commits@; downstream repositories; web)2026-10-26, rerun 2026-10-3051 queries (GitHub 16, commits 3, downstream 2, lists 20, JIRA 4, web 6), among them "only supports BYTE encoding", HnswBitVectorsFormat, FlatBitVectorsWriter, onAbortingException, tragic exception with vectors1053 hits, 0 high, 8 medium: no report of this bug
GitHub (apache/lucene, Elasticsearch, OpenSearch, OpenSearch k-NN, Solr)2026-10-26, 2026-10-3039 direct issue/PR searches in the first pass, 35 in the rerunonly the introducing PRs and the related items above
apache/lucene recent activity and open PRs2026-10-30all 120 issues and PRs updated since 2026-10-25; the changed files of all 408 open PRs, and the patches of the 18 that touch an affected fileonly #16092 has the same pattern; nothing reports or fixes this bug
Mailing lists and JIRA2026-10-26, 2026-10-30direct lists.apache.org queries on dev@, java-user@ and issues@; JQL searchesno report

History

Introduced in 9.11.0 by #13288 (3d86ff2e6a7, backport d8a81476262, #13316, April 2024), which added the BYTE-only check in addField; the aborting catch around addField dates from #992 (9.4.0). All 19 releases from 9.11.0 to 10.6.0 are affected (6 executed, 13 read) and main; not fixed upstream.

Versions tested7 rows

Introduced by #13288 "Add BitVectors format and make flat vectors format easier to extend" (benwtrent, merged 2024-04-17 as 3d86ff2e6a7 on main; branch_9x backport d8a81476262, #13316, 2024-04-22, which shipped first, in 9.11.0). git log -S 'only supports BYTE encoding' --all finds only these two commits. The other half is older: ba4bc042714 (#992, LUCENE-10592 "Build HNSW Graph on indexing", 2022-07-22; first released in 9.4.0) put addField inside the aborting catch; before 9.11.0 no stock format threw from addField, and a custom format with the same check closes the writer on 9.10.0 too. Upstream main 2f424fd428da (2026-10-30) has the same code.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails with assertions on and off (gradle and the standalone runner)
10.6.0, 10.5.2 affectedfail, -ea and -da (release jars); the probe shows the close and the rollback
10.0.0, 9.12.3, 9.11.1 portable variantfail (on 10.0.0 and 9.12.3 a FilterCodec variant of the minimal test; on 9.11.1 the study's own portable tests)
9.11.0 affectedfails, -ea and -da (portable variant); the probe shows the close
9.12.0-9.12.2, 10.1.0-10.4.0, 10.5.0, 10.5.1 readthe check and the aborting catch are at each release tag; not run
9.10.0 not applicablethe format does not exist (the repro does not compile); a custom format with the same addField check closes the writer there too
fix A (scratch worktree at 645d4fe14a20) not affectedthe minimal test, the codecs module (694 tests) and core vector suites (807 tests) pass

Independent checks

The finder, an independent reproducer, cross-model reviews, a devil's advocate and an adjudication confirmed it; a follow-up ran the release sweep and measured the loss; a tie-breaker re-ran it on main and three releases. This doc re-ran the test and validated the fix with gradle.

Checks and verdicts298 words

The verifiers and the study's reviews ran on main ea57360912ed; between it and 645d4fe14a20 no commit touches the encoding check or the addField catch.

Each independent check and its verdict
CheckVerdictWhat it found
finder (IndexingChain schema unit) candidateCode reading of the aborting classification, a JUnit on the stock jars and the root-cause demonstration.
cross-model review (OpenAI GPT-5.6, reading only) real (high)Root cause agrees, the demonstration fixes it, inputs in contract.
independent reproducer realOwn test: 2 bug cases (codec-wide, per field) fail and 3 controls (dimension limit, byte dimension mismatch, float format) pass on main, 10.5.2, 10.0.0, 9.12.3 and 9.11.1.
cross-model review, works-as-designed lens (OpenAI GPT-5.6, executed) real (high)Own lifecycle test sees the close and the loss on main and 10.5.2; its controls keep the writer open; intended argument moderate and rebutted.
intended-behaviour devil's advocate real (high)7 scope probes: float32, float16 and a re-routed FLOAT32 field close the writer; a BYTE-registered field, a null per-field format and the dimension limit do not.
adjudication confirmed (high), crashRe-ran the independent reproduction's test with -ea and -da; wording corrections.
cross-model review, refute lens (OpenAI GPT-5.6) no verdict, then not real (medium) without running codeThe study run stopped without a verdict; the 2026-10-30 rerun could run nothing, agreed with the root cause and argued works-as-designed, answered in the devil's advocate above.
tie-breaker realThe reviews disagreed on no executed fact; a probe without the test framework shows the close and the rollback on main, 10.6.0, 10.5.2 and 9.11.0, with 3 controls as expected.
this doc (2026-10-30) reproduced, fix validatedGradle in a fresh 645d4fe14a20 worktree without and with fix A; codecs module and core vector suites; format, lint and javadoc checks; revert check.

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

Finding record

LFV-121, snapshot of 2026-11-06

Registry title
A float vector field routed to HnswBitVectorsFormat is rejected inside an aborting step of the indexing chain, so the one rejected document closes the IndexWriter and rolls back every document added since the last commit
Area
codecs / HnswBitVectorsFormat x index / IndexingChain aborting-exception classification
Priority
Low, rank 671: Moved down from Medium: one float vector sent to the opt-in, experimental HnswBitVectorsFormat closes the IndexWriter and rolls back every thread's uncommitted docs, severe but loud, caused by an application programming error, and the committed index stays intact.
Impact class
crash
Impact note
Applications on 9.11.0 and later that use the opt-in, @lucene.experimental HnswBitVectorsFormat from lucene-codecs (codec-wide or per field) and send it a KnnFloatVectorField (on main also a KnnFloat16VectorField) whose field name is not yet registered as BYTE in the index: that one document throws the format's IllegalArgumentException and also closes the IndexWriter as a tragic event, so every document added since the last commit is rolled back (measured: buffered documents, documents already visible to an NRT reader, and a document another thread had added successfully), and a fresh writer closes again on the same document; -ea and -da, ordinary scale; the committed index stays intact and CheckIndex-clean, and a field already registered as BYTE gets an ordinary rejection that leaves the writer open.
Summary
You use HnswBitVectorsFormat, which accepts only byte vectors, and by mistake add a document with a float vector field (KnnFloatVectorField) that is new to the index. You expect addDocument to reject that one document with IllegalArgumentException and the writer to stay open, as the format's own test TestHnswBitVectorsFormat#testFloatVectorFails expects the exception and as happens for the same codec's dimension limit and for a vector dimension mismatch. Instead the check sits in KnnVectorsWriter#addField, which IndexingChain calls after registering the field and treats as an aborting step, so the exception also becomes the writer's tragic exception: IndexWriter closes itself, the next call throws AlreadyClosedException, and the documents added since the last commit are gone (the repro ends with 1 committed document instead of 3).
Introduced
3d86ff2e6a72 (#13288 'Add BitVectors format and make flat vectors format easier to extend', by benwtrent; branch_9x backport d8a814762623, #13316), which added FlatBitVectorsWriter#addField with the BYTE-only IllegalArgumentException [read: git log -S 'only supports BYTE encoding' over the full main history returns only this commit; git blame attributes HnswBitVectorsFormat.java:193-198 to it, unchanged since]; the other half of the root cause is older and was latent until then: ba4bc0427146 (#992, LUCENE-10592 'Build HNSW Graph on indexing', by mayya-sharipova; branch_9x a65a41855c7, #1043; first released in 9.4.0) put vectorValuesConsumer.addField after fieldInfos.add inside catch (Throwable) -> onAbortingException [read: git blame IndexingChain.java:1391-1396; exec: a custom format with the same addField check closes the writer on 9.10.0 too (follow-up history control), while the bit-vector repro does not compile there] (2024-04-17 (#13288 merged into main as 3d86ff2e6a72); 2024-04-22 (#13316, branch_9x); the aborting catch dates from 2022-07-22 (#992); 9.11.0 tagged 2024-06-06)
First affected version
9.11.0 [exec]; on the main line 10.0.0 [exec]
Affected versions
all 18 releases that ship the format: 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, and main ea57360912ed (unreleased 11.0.0) [exec: the finder repro fails 1/1 on main and on 10.5.2, each with -ea and -da; its release-portable variant fails on 9.11.0 (-ea and -da), 9.11.1, 9.12.3 and 10.0.0; the independent reproduction's own test fails its 2 bug cases (3 controls pass) on main (-ea, -da), 10.5.2, and in portable form on 10.0.0, 9.12.3 and 9.11.1]; the other 13 releases (9.12.0-9.12.2, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0, 10.5.1) [read: the addField check and the aborting catch are both present at each release tag, and getMaxDimensions is checked before fieldInfos.add in all of them]; branch_10x (1309534296e) and branch_10_6 (2763db11b0a) have the same code [read, not run]; on main only, VectorEncoding.FLOAT16 (#16383) is a second trigger [exec: the intended-behaviour check's float16 probe]; 9.10.0 and older are not affected because the format does not exist (compile error on 9.10.0 [exec]); 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 disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record, read-only: two scripted prior-report searches, 51 queries (16 GitHub, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web) and 29 queries (lists, JIRA, commits, downstream); 39 direct GitHub issue/PR searches on apache/lucene; direct searches on Elasticsearch, OpenSearch, OpenSearch k-NN and Solr; 9 direct lists.apache.org queries each on dev@, java-user@ and issues@; 5 JQL searches; 14 web searches; the changed files of 225 of the 413 open apache/lucene PRs (16 touch an affected file, the hunks of the 6 closest read); upstream commits by path; git history and CHANGES.txt; plus the finder's and the independent reproduction's and the intended-behaviour check's searches); related prior items, none of them this bug: #13288 and #13316 (introduced the format and its addField check; the review calls it validation and never discusses the writer closing), #12436 (2023 review thread in which maintainers kept the dimension-limit check ahead of FieldInfo registration because a check in addField would come after registration; design context that predates the bug), #992 (LUCENE-10592, added the aborting catch), #16211 and #14025 (open feature requests that mention the format), #7049 (LUCENE-5987), #6673 (LUCENE-5611), #6724 (LUCENE-5662), #7111 (LUCENE-6049) and #9252 (LUCENE-8205) (aborting/non-aborting design and precedents), #15540, #15125, #16708 and #16383 (different defects or features); downstream: Elasticsearch's own ES815HnswBitVectorsFormat does not use Lucene's class, and no use of HnswBitVectorsFormat was found in Solr, OpenSearch or OpenSearch k-NN (default-branch code search, not exhaustive)
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a fresh scratch worktree (fails with assertions on and off; with fix A it passes, together with the whole codecs module, 694 tests, core vector and writer suites, 807 tests, and the format, lint and javadoc checks; after reverting the fix it fails again). Release jars: 10.6.0 and 10.5.2 fail with assertions on and off; 9.11.0 (on and off), 9.11.1, 9.12.3 and 10.0.0 fail with a release-portable variant; 9.10.0 has no such format. An independent probe without the test framework shows the close and the rollback on main, 10.6.0, 10.5.2 and 9.11.0. JDK 25.0.3, Linux aarch64. The verifiers and cross-model reviews ran on main ea57360912ed; between it and 645d4fe14a20 no commit touches the encoding check or the addField catch.

Lucene and upstream sources

Finding record
  • lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:50-58 (class javadoc, @lucene.experimental at :56) and :193-197 (FlatBitVectorsWriter#addField throws IllegalArgumentException 'HnswBitVectorsFormat only supports BYTE encoding' for any non-BYTE encoding)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1317 (initializeFieldInfo), :1331-1336 (validateMaxVectorDimension with getMaxDimensions, before registration, non-aborting), :1337-1338 (fieldInfos.add), :1390-1397 (vectorValuesConsumer.addField inside catch (Throwable) -> onAbortingException)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:594-603 (maybeAbort: abort() and onTragicEvent)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (class javadoc: IndexWriter closes itself on an Error or a checkpoint disaster) and :1465-1466 (addDocument: after an Exception the index is consistent, 'but this document may not have been added')
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:6108-6112 (getTragicException javadoc: closed as a side-effect of a tragic exception, 'e.g. disk full while flushing a new segment')
  • lucene/CHANGES.txt:11544-11546 (LUCENE-5987: IndexWriter forcefully closes itself on an aborting exception, 'an exception that would otherwise cause silent data loss')
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1592-1613 (testNullStoredField: after an input IllegalArgumentException, assertNull(iw.getTragicException()) and the earlier good document is kept; similar assertions at :1636-:1811) and lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:779 (testIllegalDimensionTooLarge)
  • lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/TestHnswBitVectorsFormat.java:54-63 (testFloatVectorFails expects IllegalArgumentException from addDocument; the message check at :61 is not asserted; writer state is not checked)
  • lucene/codecs/src/resources/META-INF/services/org.apache.lucene.codecs.KnnVectorsFormat:16 (the format is registered for SPI lookup)
  • SECURITY.md:28, 42, 46, 50 (threat model, cited by the security record)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:176-180, (at 645d4fe14a20) :641, :685-699, :1372, :1386-1391, :1392-1414, :1445-1451, :1625
  • lucene/core/src/java/org/apache/lucene/index/VectorValuesConsumer.java:52-66, (at 645d4fe14a20) :68-71; lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldKnnVectorsFormat.java:118-121, :147-185
  • lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:56, (at 645d4fe14a20) :139-148, :159-162, :192-198
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:56-60, (at 645d4fe14a20) :291, :310, :594-605; lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1492, :1625-1641, :2566-2571, :6068-6084, :6098-6106
Why it looks unintended
  • lucene/codecs/src/test/org/apache/lucene/codecs/bitvectors/TestHnswBitVectorsFormat.java:54-63 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1592-1614; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:779-795; lucene/core/src/test/org/apache/lucene/codecs/perfield/TestPerFieldKnnVectorsFormat.java:217-244
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, (at 645d4fe14a20) :1465-1466, :6108-6112; lucene/CHANGES.txt:2795-2796, :11577-11579, :12384-12385
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/KnnVectorsFormat.java:105-114; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/perfield/PerFieldKnnVectorsFormat.java:93-96; lucene/test-framework/src/java/org/apache/lucene/tests/codecs/asserting/AssertingKnnVectorsFormat.java:51, :67-70

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and the executed probe numbers (1 committed document instead of 3; an NRT-visible document lost).

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.