All findings · Lucene finding · LFV-1189 · termdoc-duplicate-binary-term-closes-indexwriter

A repeated non-UTF-8 binary term in a DOCS_AND_CUSTOM_FREQS field can close IndexWriter instead of skipping the document

A DOCS_AND_CUSTOM_FREQS document that repeats a term should be skipped while the writer stays open. If the repeated term is binary and not valid UTF-8, such as the lone byte 0xC3, building the error message can throw and close IndexWriter, losing all uncommitted documents (10.5.0 to 10.6.0, main).

Summary

Priority
Low, rank 673In an opt-in DOCS_AND_CUSTOM_FREQS field, a document that repeats a binary term whose bytes are not valid UTF-8 (such as the single byte 0xC3) closes the IndexWriter and loses uncommitted documents, where it should skip only that document, in releases 10.5.0 through 10.6.0. It needs the new opt-in index option, raw binary terms and a repeated term (a user error the option forbids), not every malformed term triggers it, and committed data stays intact.
Impact
crash
Since
10.5.0 (2026, #15584, together with the index option)introduced 2026-04-24
Affected
10.5.0, 10.5.1, 10.5.2, 10.6.0 (all run) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of TermsHashPerField.add's contract, witnessed by an executable repro

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

The repeat is caught as intended; what fails is the error message, which decodes the term as UTF-8.

Background: term-doc fields and how IndexWriter fails a document

  1. DOCS_AND_CUSTOM_FREQS (new in 10.5.0) indexes a custom score per term instead of a count, so each term may occur only once per document.
  2. IndexWriter fails a bad document in one of two ways. A non-aborting rejection throws IllegalArgumentException and skips just that document. An aborting failure closes the writer and rolls back to the last commit, because its in-memory state may be damaged.
A flow from a new document (the term 0xC3 twice) into the indexing step, an in-memory buffer of not yet committed documents and, through commit(), the index on disk; from the indexing step a green arrow labelled 'the repeat is detected' leads to a green panel 'Rejection of one document (non-aborting)', where only that document is skipped and the writer stays open, and a red arrow labelled 'but building the message (utf8ToString()) throws' leads to a red panel 'Aborting failure (unexpected exception)', where the buffer is dropped and the writer closes itself; a red strip explains that the repeated term is meant to take the green path, and two small panels compare expected (the document is skipped, the writer stays open) with actual (the writer closes, the buffered documents are lost).
IndexWriter skips a document it rejects but closes itself on an unexpected exception, and building the duplicate-term message for a non-UTF-8 term turns the first case into the second. Generated with AI, checked against the code.
Diagram of the bug: a document with the one-byte term C3 twice in a DOCS_AND_CUSTOM_FREQS field. The repeat raises the duplicate-term exception, the message decoration calls utf8ToString, which throws ArrayIndexOutOfBoundsException, and IndexingChain sends that to the aborting path, so IndexWriter closes and the uncommitted documents are lost; an ASCII term repeated takes the non-aborting path and the writer stays open.
The document's second {C3} is caught as a duplicate, but building the error message decodes the term as UTF-8 and throws; the indexing chain then takes the aborting path and the writer closes instead of skipping the document. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index raw binary terms into a field with IndexOptions.DOCS_AND_CUSTOM_FREQS, which allows each term once per document. You commit 1 document, add a 2nd, then add a 3rd that holds the one-byte term {0xC3} twice.
  2. You expect what happens when foo is repeated: IllegalArgumentException: Document update skipped due to duplicate termdoc term, the writer stays open, and after the next commit the index holds 2 documents.
  3. Lucene does catch the repeat, but to put the term into the error message it decodes the bytes as UTF-8 (utf8ToString). 0xC3 starts a 2-byte character and there is no 2nd byte, so the decoder reads past the 1-byte array: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1.
  4. That exception is not one the indexing chain treats as a per-document rejection, so IndexWriter closes itself: isOpen() is false, and the index holds 1 document. The 2nd document, never committed, is lost.

Who is affected

Applications on 10.5.0 to 10.6.0 that opt into DOCS_AND_CUSTOM_FREQS for raw binary terms and pass a document that repeats one ending in a truncated UTF-8 sequence. One such document closes the writer and rolls back all uncommitted documents. Terms built from characters, as ordinary analyzers emit them, are valid UTF-8 and are rejected correctly.

Mechanism, terms and numbers637 words

IndexOptions.DOCS_AND_CUSTOM_FREQS (new in 10.5.0, #15584) stores a custom score per term and allows each term only once per document. When a document repeats a term, #15584 made addDocument reject just that document with the non-aborting IllegalArgumentException("Document update skipped due to duplicate termdoc term"); TestCustomTermFreq.testRepeatedTerms asserts the writer stays open. On the way, TermsHashPerField.add re-throws the internal DuplicateTermException with the term appended through termBytes.utf8ToString() (TermsHashPerField.java:203 at 5adff78bb168). For some binary terms that are not valid UTF-8, such as a BytesRef that holds just the byte 0xC3, that decode throws ArrayIndexOutOfBoundsException. The indexing chain treats DuplicateTermException (like a too-long term) as a per-document rejection and every other exception as aborting, so the writer records the AIOOBE as its tragic exception and closes, and every document added since the last commit is lost. A one-line fix renders the term with Lucene's existing Term.toString(BytesRef), which prints non-UTF-8 terms as bytes.

The mechanism (main 5adff78bb168, under lucene/core/src/java/org/apache/lucene/)

  1. The rejection starts as intended. For a term-doc field, a term already seen in this document throws DuplicateTermException("field 'f' has duplicate term") (index/FreqProxTermsWriterPerField.java:193-195).
  2. The message decoration decodes the term. TermsHashPerField.add catches it and re-throws new DuplicateTermException(e.getMessage() + " '" + termBytes.utf8ToString() + "'") (index/TermsHashPerField.java:200-204, the call at :203). This is the fix site.
  3. utf8ToString is documented to throw on bad input: "May throw an AssertionError or a RuntimeException if the data is not well-formed UTF-8" (util/BytesRef.java:133-136). For {C3}, a 2-byte lead byte that ends its array, UnicodeUtil.UTF8toUTF16 reads past the end of the 1-byte array (util/UnicodeUtil.java:649).
  4. The indexing chain classifies by exception type. Around termsHashPerField.add (index/IndexingChain.java:2075): MaxBytesLengthExceededException becomes a non-aborting IAE that prints the term's bytes with Arrays.toString (:2076-2094); DuplicateTermException becomes the non-aborting IAE (:2095-2097); every other Throwable goes to onAbortingException (:2098-2100, :176-180). That marks the per-thread writer aborted (index/DocumentsWriterPerThread.java:56-60); its maybeAbort drops the buffered documents and reports a tragic event (:594-605), which IndexWriter records (index/IndexWriter.java:6067-6083); the catch in IndexWriter.updateDocuments then calls maybeCloseOnTragicEvent (:1632-1640), which rolls the writer back and closes it (:6097-6104).
  5. Reach. Field(String, BytesRef, IndexableFieldType) builds an untokenized binary field (document/Field.java:187-226) that is inverted as a token stream (Field.java:460-461), so it takes this path. A custom TokenStream with binary terms reaches it too (the devil's-advocate verifier's probe).

Scope

  • Only some non-UTF-8 terms trigger it. With assertions off, the decode throws only when it runs off an array: a truncated multi-byte sequence at the end of the term's backing array, such as [c3] or [41 e2 82]. The decoder reads past BytesRef.length, so the backing array is what counts: a [c3] slice followed by another byte only garbles the message, while a slice that ends in a 4-byte lead byte ([f0] of [f0 90 80 80]) overruns the decoder's output array and closes the writer too.
  • A lone continuation byte such as [80 41] gives a garbled message ('\u0080A': an invisible U+0080, then A) and the writer stays open; with assertions on, that one closes the writer too, through an AssertionError.
  • An ASCII term (foo, [41 42]) or a valid UTF-8 term ([c3 a9], "é") repeated the same way gets the intended rejection, and the writer stays open.
  • Committed data is not affected.
  • Not run: the BINARY invert path (IndexingChain.invertTerm, :2127-2170). StringField and KeywordField fix their index options to DOCS, but a custom IndexableField that returns InvertableType.BINARY, or an untokenized column of the columnar API (10.6.0), can use this option. By reading, invertTerm has neither the duplicate-term catch nor the aborting catch, so there the caller would get the raw exception instead of the IAE.
Exposure and likelihood175 words
  • Who. Code that indexes raw binary terms into a DOCS_AND_CUSTOM_FREQS field (no Lucene field class uses the option) and lets a document repeat one of them, which the option forbids. Only some byte patterns trigger it (see Scope above).
  • Default vs opt-in. An opt-in index option; default IndexWriterConfig and codec otherwise.
  • Effect. Loud, not silent: addDocument throws, the writer closes, later calls throw AlreadyClosedException until a new writer is opened, and all documents added since the last commit are lost. No corruption was observed: the writer rolls back to the last commit, and committed data stays intact.
  • Scale. One document, a one-byte term; fixed input, single thread.
  • Likelihood. Rare: it needs the new option, raw binary terms, a repeated term (a user error) and a byte pattern that makes the decoder run past the end.
  • Text vs bytes. CharTermAttribute terms are encoded to valid UTF-8 (unpaired surrogates become U+FFFD), so they get the intended rejection; a token stream that emits raw bytes (BytesTermAttribute, "if binary terms should be indexed") can reach the bug.

Minimal repro

One JUnit test method on the public API: an untokenized DOCS_AND_CUSTOM_FREQS field type, one document with the one-byte term {0xC3} added twice, addDocument. It expects the IllegalArgumentException and an open writer, so it doubles as the regression test. Fixed input, single thread.

TestRepeatedBinaryTermDocTerm.java (lucene/core, package org.apache.lucene.index, 1 test)43 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestRepeatedBinaryTermDocTerm extends LuceneTestCase {

  public void testRepeatedNonUtf8TermDoesNotCloseWriter() throws Exception {
    FieldType ft = new FieldType();
    ft.setTokenized(false);
    ft.setIndexOptions(IndexOptions.DOCS_AND_CUSTOM_FREQS);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new Field("f", new BytesRef(new byte[] {(byte) 0xC3}), ft));
      doc.add(new Field("f", new BytesRef(new byte[] {(byte) 0xC3}), ft));
      // Expected: IAE, writer stays open; actual: utf8ToString throws and closes the writer.
      expectThrows(IllegalArgumentException.class, () -> w.addDocument(doc));
      assertTrue(w.isOpen());
    }
  }
}
Command
cp $REPRO/TestRepeatedBinaryTermDocTerm.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestRepeatedBinaryTermDocTerm -Ptests.seed=DEADBEEF
# same failure with -Ptests.asserts=false
Expected
addDocument throws IllegalArgumentException: Document update skipped due to duplicate termdoc term and the writer stays open.
Observed
addDocument throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 from BytesRef.utf8ToString, and the writer has closed itself.

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

More files, environment, full output238 words

TestRepeatedBinaryTermDocTerm (one method, extends LuceneTestCase, package org.apache.lucene.index of the lucene/core tests, Apache license header). Its input is fixed (LuceneTestCase still randomizes codec, similarity and locale, so the command pins a seed; an equivalent test also failed with four other seeds) and it passes checkGoogleJavaFormat. It fails the same way with assertions on and off, and passes unchanged with the recommended fix.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.5.0, 10.5.1, 10.5.2, 10.6.0: fail (release jars, assertions off); 10.4.0 and 9.12.3: no such index option
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestRepeatedBinaryTermDocTerm.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestRepeatedBinaryTermDocTerm -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on)
TestRepeatedBinaryTermDocTerm > testRepeatedNonUtf8TermDoesNotCloseWriter FAILED
    junit.framework.AssertionFailedError: Unexpected exception type, expected IllegalArgumentException but got java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:48CCA36ED319B27F]:0)
        ...
        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
            at org.apache.lucene.util.UnicodeUtil.UTF8toUTF16(UnicodeUtil.java:649)
            at org.apache.lucene.util.BytesRef.utf8ToString(BytesRef.java:139)
            at org.apache.lucene.index.TermsHashPerField.add(TermsHashPerField.java:203)
            at org.apache.lucene.index.IndexingChain$PerField.invertTokenStream(IndexingChain.java:2075)
            ...
            at org.apache.lucene.index.IndexWriter.addDocument(IndexWriter.java:1492)

# same with -Ptests.asserts=false

# control probe (1 committed doc, 1 buffered doc, then the test document; assertions off)
PROBE ASCII 'foo' twice: thrown=java.lang.IllegalArgumentException: Document update skipped due to duplicate termdoc term ... writerOpen=true tragic=null committedNumDocs=2
PROBE binary {C3} once: thrown=none writerOpen=true tragic=null committedNumDocs=3
PROBE binary {C3} twice: thrown=java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 writerOpen=false tragic=java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 committedNumDocs=1

Why it looks unintended

  • #15584 made this rejection non-aborting on purpose: its handling "merely skips the doc being indexed (same as for a too-large term)".
  • TestCustomTermFreq.testRepeatedTerms asserts that after a repeated term the writer stays open and only that document is skipped.
  • The decode only builds a message: utf8ToString is documented to throw on non-UTF-8 bytes, and Term.toString exists to print such terms safely.
The full argument140 words
  • Intent of the feature's author. #15584's author, Michael Sokolov, on 2026-03-23 (https://github.com/apache/lucene/pull/15584#issuecomment-4110154752): "found that the exception I raised for duplicate terms in termdoc fields would cause the writer to crash, so added some exception handling that merely skips the doc being indexed (same as for a too-large term)".
  • Test. TestCustomTermFreq.testRepeatedTerms (lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:106-135) expects the IAE, the cause message "field 'field' has duplicate term 'foo'", then: "ensure IndexWriter remains open and doc was skipped".
  • Binary terms are legal on this option. The same {C3} term once per document is indexed fine; IndexOptions.java:52-56 requires each term once per document, not UTF-8 terms.
  • A safe helper exists. Term.toString(BytesRef): "If the term is not unicode, the raw bytes will be printed instead" (index/Term.java:104-122). The too-long-term handler in the same try prints raw bytes and never decodes (IndexingChain.java:2076-2094).
The devil's-advocate check141 words

The case for intended: repeating a term breaks the option's rule ("Each term can only occur once per document", IndexOptions.java:52-56), so any exception is acceptable. Also raised: the @throws line of Field(String, BytesRef, IndexableFieldType) says it throws if the type "is indexed()" (Field.java:196-197), and IndexOptions is @lucene.experimental (IndexOptions.java:24).

Why it fails: the rule justifies rejecting the document, which Lucene already does without closing the writer for every valid UTF-8 term; nothing documents closing the writer for some byte patterns only. The @throws line is stale: the constructor does not throw for an indexed untokenized type, and the bug reproduces without that constructor through a TokenStream. @lucene.experimental means the API may change, not that misuse may close the writer. Two executed cross-model reviews and a devil's-advocate verifier built this case and all rated the bug real.

Suggested fix

Recommended Render the term with Term.toString(termBytes) (fix A)

Render the term with Lucene's existing Term.toString(termBytes) instead of termBytes.utf8ToString() in TermsHashPerField.add (one line). Valid UTF-8 terms keep their text; others print as bytes, '[c3]'. The repro passes with assertions on and off, and it and the 3 closest suites (59 tests) pass.

-        throw new DuplicateTermException(e.getMessage() + " '" + termBytes.utf8ToString() + "'");
+        throw new DuplicateTermException(e.getMessage() + " '" + Term.toString(termBytes) + "'");
All options and trade-offs4 options · 412 words

Option A was built and checked through gradle on 5adff78bb168; B passed the finder's test only; C was not built.

Render the term with Term.toString(termBytes) (fix A) (validated by the checks) (recommended)

In TermsHashPerField.add (TermsHashPerField.java:203), build the message with Term.toString(termBytes), which decodes strictly and falls back to the hex bytes of BytesRef.toString() for anything that is not well-formed UTF-8 (Term.java:104-122, BytesRef.java:145-157). Precedent: #7850 (LUCENE-6792) fixed the same mistake in TermsQuery.toString the same way.

-        throw new DuplicateTermException(e.getMessage() + " '" + termBytes.utf8ToString() + "'");
+        throw new DuplicateTermException(e.getMessage() + " '" + Term.toString(termBytes) + "'");

Trade-offs

  • Valid UTF-8 terms keep their text ('foo'), so testRepeatedTerms passes unchanged; non-UTF-8 terms print as bytes, '[c3]' and '[41 e2 82]', and the writer stays open (executed in the diagram probe with the fix, assertions on and off).
  • Malformed input is reported, not guessed: [80 41] also prints as bytes, '[80 41]', instead of the garbled '\u0080A' (executed).
  • Cost only on the error path; no hot-path change.

What the checks validated

  • gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: the repro (1), TestCustomTermFreq (15), TestTermsHashPerField (3) and TestIndexWriterExceptions (40, 3 skipped): 59 tests, 0 failures, 3 skipped. :lucene:core:checkGoogleJavaFormat passes. The whole core module was not run.
  • The adjudication's run: the independent reproducer's test passes with the fix (assertions on and off) and TestCustomTermFreq 15 of 15.

Keep utf8ToString and fall back to termBytes.toString() on failure

Wrap the decode in try { ... } catch (RuntimeException | AssertionError notUtf8) and print the bytes in the catch (the finder's fix).

Trade-offs

Catches AssertionError, which is unusual; with assertions off a malformed term that does not throw ([80 41]) still yields a garbled message. Passes the finder's test with assertions on; not run through gradle.

Build the message in IndexingChain's DuplicateTermException catch instead

Drop the decoration from TermsHashPerField.add and add the term, through Term.toString, where IndexingChain.invertTokenStream turns the exception into the IAE (IndexingChain.java:2095-2097).

Trade-offs

Covers the token-stream path, in two files, but not the BINARY path: IndexingChain.invertTerm has no DuplicateTermException catch, so it would need the same handling, which fix A makes unnecessary. Not built.

Drop the term from the message (not recommended)

Throw the original DuplicateTermException unchanged.

Trade-offs

Loses useful context and changes the cause message that testRepeatedTerms asserts.

Downstream note

A product that lets its users supply raw binary terms for such a field could have one malformed, repeated term close its writer; text through an ordinary analyzer becomes valid UTF-8 and cannot trigger it.

How it was found

A proof-obligation audit of TermsHashPerField.add's contract: a repeated term in a term-doc field must always end in the non-aborting duplicate-term rejection, for every term byte sequence. The message decoration was the one step that can throw for some bytes; a one-test public-API JUnit then witnessed the writer closing.

The method in full124 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder stated the obligation for TermsHashPerField.add: when positionStreamSlice raises DuplicateTermException, add must raise a DuplicateTermException too, whatever the term's bytes, because IndexingChain keeps the writer open only for that type. Discharging it step by step left one step that can throw: termBytes.utf8ToString(), documented to throw on malformed UTF-8.
  • The finder then wrote a public-API JUnit test that fails on main with assertions on and off, and on 10.6.0 and 10.5.0, plus a control probe (ASCII repeat, binary term once, binary term twice).
  • An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-03). Background only: #4016 (an old general note that term toString() methods assume UTF-8) and #7850 (the same mistake fixed elsewhere).

Searches3 searches

No public report of this bug was found. Related, not reports: #4016 (LUCENE-2942, open since 2011) says in general that toString() debug methods wrongly assume UTF-8; it predates this code by 15 years and names neither this call site, exception messages nor a closed writer. #7850 (LUCENE-6792) fixed the same mistake in TermsQuery.toString with Term.toString. #15584 (the introducing PR) was read in full; no comment mentions binary or non-UTF-8 terms. #15984 (open) shows the intended rejection for an ASCII term. #16534 and #16269 (open PRs) touch TermsHashPerField.java but not the re-throw. Limits: keyword search; GitHub search does not index review comments; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream repos2026-11-03, re-checked the same daya 51-query scripted prior-report search (16 issue/PR, 3 commit, 2 downstream, 4 JIRA, 6 web), the finder's 25-query run, about 35 direct searches incl. everything created since 2026-11-01no report; #4016, #7850, #15584, #15984 are the nearest items
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs and of the 22 updated since 2026-11-02#16534 and #16269 touch TermsHashPerField.java; neither changes the re-throw
Mailing lists (dev@, java-user@, issues@, commits@)2026-11-0320 list queries and direct lists.apache.org searchesonly the issues@ mirror of #15984 and commit mails

History

Introduced with the index option by #15584 (Michael Sokolov, merged 2026-04-24; first release 10.5.0), in the same diff as the non-aborting rejection it defeats. Every release run, 10.5.0 to 10.6.0, and main fail; 10.4.0 and 9.12.3 lack the option. Not fixed upstream.

Versions tested4 rows

Introduced by #15584 "Compute 'length' of fields with custom term frequencies as docFreq" (Michael Sokolov): 5158a52a082 on main, cherry-picked to branch_10x as 3a55cc9ac5e, both 2026-04-24. The same diff added DOCS_AND_CUSTOM_FREQS, the duplicate-term throw, the non-aborting catch and the utf8ToString decoration. First release 10.5.0 (CHANGES: "GITHUB#15584: Add support for termdoc fields that use custom term freqs").

TermsHashPerField.java:199-205 is byte-identical in 10.5.0, 10.6.0 and main; only the throwing line in UnicodeUtil.UTF8toUTF16 moves (647 in 10.5.x, 650 in 10.6.0, 649 on main). Not fixed upstream: on upstream main 1c2072f0284 (2026-11-02) the last commit on TermsHashPerField.java is still 5158a52a082.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
10.6.0 affectedfails (-ea, -da), same AIOOBE at UnicodeUtil.java:650; the exact repro fails too (-da)
10.5.0, 10.5.1, 10.5.2 affectedfail (the exact repro, -da, on all three; the hunt's test with the same input also with -ea on 10.5.0), AIOOBE at UnicodeUtil.java:647
10.4.0, 9.12.3 no APIthe repro does not compile: IndexOptions.DOCS_AND_CUSTOM_FREQS does not exist

Independent checks

A finder, an independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.

Checks and verdicts383 words

The cross-model reviews ran on main ea57360912ed, where TermsHashPerField, FreqProxTermsWriterPerField, BytesRef, UnicodeUtil, Term and Field are identical to 5adff78bb168 and IndexingChain.java differs only in unrelated columnar vector code (#16734). The other checks on main ran on 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedTest fails on main (assertions on and off), 10.6.0 and 10.5.0; control probe: foo repeated keeps the writer open, {C3} repeated closes it and the index keeps 1 document.
independent reproducer realOwn test (also asserts the IAE message) fails on main, 10.6.0, 10.5.2 and 10.5.0; controls foo and C3 A9 keep the writer open with both documents.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own 2-test class fails 1 of 2, controls pass; a direct test sees the tragic close and the rollback; with the fix both pass, as does testRepeatedTerms.
cross-model review, intended lens (OpenAI GPT-5.6, executed) real (high)The once-per-document rule supports rejecting the document, not closing the writer for malformed UTF-8 only; own test passes with the fix.
devil's-advocate verifier realSame close through a TokenStream with binary terms ([41 e2 82]); [80 41] aborts only with assertions on; every intended argument fails.
adjudication confirmed, high, LowClean re-run fails (assertions off, new seed); fix A passes the reproducer's test (on and off) and TestCustomTermFreq 15 of 15.
separate re-run, audit and final repro check reproduced; confirmedRe-run and audit: the independent reproducer's equivalent test fails on main with assertions on and off (four seeds), and the re-run's fix check passes (assertions off). Final repro check: the one-test repro fails on main with assertions on and off and passes with fix A.
this doc reproduced; fix A validatedgradle without and with the fix, assertions on and off; with the fix, the repro and the 3 closest suites pass (59 tests, 3 skipped).
fact checks (independent; cross-model, OpenAI GPT-5.6) real, Low; wording fixedThe fact check re-ran the exact test through gradle on main (assertions on and off) and on the 10.5.0 to 10.6.0 release jars, and fix A. Both checks found only wording errors; they are fixed in this doc.

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

Finding record

LFV-1189, snapshot of 2026-11-06

Registry title
A term repeated in one document of a DOCS_AND_CUSTOM_FREQS field closes IndexWriter, instead of skipping the document, when the term is a non-UTF-8 binary term such as the single byte 0xC3: the duplicate-term rejection builds its message with utf8ToString, which throws (since 10.5.0)
Area
index / IndexingChain inversion, term-doc (DOCS_AND_CUSTOM_FREQS) duplicate-term rejection (TermsHashPerField.add) x aborting-exception classification
Priority
Low, rank 673: In an opt-in DOCS_AND_CUSTOM_FREQS field, a document that repeats a binary term whose bytes are not valid UTF-8 (such as the single byte 0xC3) closes the IndexWriter and loses uncommitted documents, where it should skip only that document, in releases 10.5.0 through 10.6.0. It needs the new opt-in index option, raw binary terms and a repeated term (a user error the option forbids), not every malformed term triggers it, and committed data stays intact.
Impact class
crash
Impact note
Applications on 10.5.0 through 10.6.0 and main that opt into IndexOptions.DOCS_AND_CUSTOM_FREQS (new in 10.5.0; no core field type uses it) for binary terms and pass a document that repeats a term whose bytes make the UTF-8 decoder run past the end of the term's backing array (for example the single byte 0xC3, or a term ending in a truncated multi-byte lead byte): the repeat, which Lucene is designed to reject by skipping only that document, instead closes the IndexWriter as a tragic event, so the uncommitted buffered documents are lost (executed: committed numDocs 1, the buffered document gone) and later calls throw AlreadyClosedException until a new writer is opened; default IndexWriterConfig, -ea and -da, ordinary scale; with -da other malformed bytes only garble the message and the writer stays open, with -ea more of them close the writer through an AssertionError; committed data is unaffected.
Summary
You index binary terms into a field with IndexOptions.DOCS_AND_CUSTOM_FREQS, which allows each term only once per document, and one document repeats a term that is not valid UTF-8, for example new BytesRef(new byte[] {(byte) 0xC3}) added twice as an untokenized Field. You expect addDocument to reject only that document with the non-aborting IllegalArgumentException 'Document update skipped due to duplicate termdoc term' and the writer to stay open, as happens for an ASCII or valid UTF-8 term and as the upstream test TestCustomTermFreq.testRepeatedTerms asserts. Instead TermsHashPerField.add builds the rejection message with termBytes.utf8ToString(), which throws ArrayIndexOutOfBoundsException for these bytes; the indexing chain treats that as an aborting failure, so IndexWriter closes itself and every uncommitted document is lost.
Introduced
5158a52a082b842c29918c8b955323d2a0848eb5 on main, 'Added DOCS_AND_CUSTOM_FREQS whose 'length' is docFreq (#15584)' by msokolov (PR #15584, 'Compute 'length' of fields with custom term frequencies as docFreq', milestone 10.5.0), cherry-picked to branch_10x as 3a55cc9ac5e96e39a75e837e326085c5efdc894d; the same diff added IndexOptions.DOCS_AND_CUSTOM_FREQS, the DuplicateTermException throw in FreqProxTermsWriterPerField, the non-aborting DuplicateTermException catch in IndexingChain.invertTokenStream and the termBytes.utf8ToString() message decoration in TermsHashPerField.add, so the bug is as old as the feature [read: git blame of TermsHashPerField.java lines 200-204; git log -S DuplicateTermException returns only this commit; git tag --contains 3a55cc9ac5e9 lists 10.5.0, 10.5.1, 10.5.2 and 10.6.0; exec: 10.4.0 lacks the API, 10.5.0 fails] (2026-04-24 (author date 2026-04-24T13:07:17Z, merged the same minute; the branch_10x cherry-pick was committed 2026-04-24 09:34:18 -0400); first release 10.5.0)
First affected version
10.5.0 [exec: the finder's test fails with -ea and -da]; 10.4.0 and older, all 9.x included, lack the API [exec: the repro does not compile on 10.4.0 and 9.12.3, cannot find symbol IndexOptions.DOCS_AND_CUSTOM_FREQS]
Affected versions
10.5.0, 10.5.1, 10.5.2, 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT) [exec, JDK 25.0.3, Linux aarch64: the finder's test fails on 10.5.0 (-ea, -da), 10.5.1 (-da), 10.5.2 (-da), 10.6.0 (-ea, -da) and main (-ea, -da) in the history runs; the independent reproduction's test fails on main (-ea, -da) and on 10.6.0, 10.5.2 and 10.5.0 (-da); the adjudication's and the re-runs fail on main]; not affected: 10.4.0 and all 9.x, latest 9.12.3 (API absent) [exec: compile error]; TermsHashPerField.java lines 199-205 are byte-identical at 10.5.0, 10.6.0 and main, and only the throwing line in UnicodeUtil.UTF8toUTF16 moves (647 in 10.5.x, 650 in 10.6.0, 649 on main) [read]; not new in 10.6 (10.5.x fails); not fixed upstream as of main 1c2072f0284 (author date 2026-11-02), whose last commit on TermsHashPerField.java is still 5158a52a082 [read, GitHub API 2026-11-03]
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: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): a scripted prior-report search of 51 queries (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 331 distinct hits, 0 high) plus the finder's run of 25 queries, about 35 direct GitHub searches over apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr (including everything created since 2026-11-01), PR #15584 read in full (body, comments, reviews, review comments), the changed files of all 409 open apache/lucene PRs and of the 22 open PRs updated since 2026-11-02 (the two open PRs touching TermsHashPerField.java, #16534 and #16269, do not change the rethrow), upstream commits by path, and direct lists.apache.org queries. Related items, none of them this bug: #15584 (introducing PR; its author made the duplicate-term rejection non-aborting on purpose and nobody discussed binary terms), #15984 (open nightly failure showing the intended non-aborting rejection for an ASCII term), #4016 (LUCENE-2942, open since 2011: toString() debug methods wrongly assume UTF-8; it proposes the fallback technique but predates this code by 15 years and names neither this call site, exception messages, IndexWriter nor lost documents), #7850 (LUCENE-6792, the same mistake in TermsQuery.toString, fixed with Term.toString as its own issue), #4400 (LUCENE-3327, a utf8ToString AIOOBE in a verbose FST test).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree, with assertions on and off, without and with the fix (the repro, TestCustomTermFreq, TestTermsHashPerField, TestIndexWriterExceptions, checkGoogleJavaFormat). On the release jars of 10.5.0, 10.5.1, 10.5.2 and 10.6.0 the repro fails (assertions off; the hunt's test with the same input also failed with them on for 10.5.0 and 10.6.0); 10.4.0 and 9.12.3 lack the index option. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/TermsHashPerField.java:190-208 (at 5adff78bb168) (add: try positionStreamSlice, catch (DuplicateTermException e) at :202, rethrow new DuplicateTermException(e.getMessage() + " '" + termBytes.utf8ToString() + "'") at :203: fix site)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2074-2100 (at 5adff78bb168) (invertTokenStream: termsHashPerField.add at :2075; MaxBytesLengthExceededException -> IAE that prints the term prefix with Arrays.toString, :2076-2094; DuplicateTermException -> non-aborting IAE 'Document update skipped due to duplicate termdoc term', :2095-2097; any other Throwable -> onAbortingException, :2098-2100)
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:193-195 (at 5adff78bb168) (isTermDoc and the term already in this document -> DuplicateTermException "field '...' has duplicate term")
  • lucene/core/src/java/org/apache/lucene/index/IndexOptions.java:52-56 (at 5adff78bb168) (DOCS_AND_CUSTOM_FREQS: 'Each term can only occur once per document')
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:133-141 (at 5adff78bb168) (utf8ToString: 'May throw an AssertionError or a RuntimeException if the data is not well-formed UTF-8')
  • lucene/core/src/java/org/apache/lucene/index/Term.java:104-108 (at 5adff78bb168) (Term.toString(BytesRef): 'If the term is not unicode, the raw bytes will be printed instead')
  • lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:106-135 (at 5adff78bb168) (testRepeatedTerms: the repeat is rejected with the IAE, its cause is the DuplicateTermException "field 'field' has duplicate term 'foo'", and 'ensure IndexWriter remains open and doc was skipped')
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:193-195 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/TermsHashPerField.java:190-208 (at 5adff78bb168) (add; catch at :202, re-throw with utf8ToString at :203)
  • lucene/core/src/java/org/apache/lucene/util/BytesRef.java:133-141 (at 5adff78bb168) (utf8ToString javadoc and body); util/UnicodeUtil.java:640-650 (UTF8toUTF16)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:176-180 (at 5adff78bb168) (onAbortingException), 2074-2100 (invertTokenStream catches)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:56-60 (at 5adff78bb168) (onAbortingException), 310 and 594-605 (maybeAbort: abort, then the tragic event); index/IndexWriter.java:6067-6083 (onTragicEvent), 1632-1640 (updateDocuments' catch calls maybeCloseOnTragicEvent), 6097-6104 (maybeCloseOnTragicEvent -> rollbackInternal)
  • lucene/core/src/java/org/apache/lucene/document/Field.java:187-226, (at 5adff78bb168) 460-461
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1958-1963 (at 5adff78bb168) (BINARY -> invertTerm), 2127-2170 (invertTerm: only a MaxBytesLengthExceededException catch); document/column/ColumnFieldAdapter.java:245-250 (untokenized columns are BINARY); test lucene/core/src/test/org/apache/lucene/index/TestDocumentWriter.java:431 (a custom BINARY field)
Who is affected
  • Lucene main 5adff78bb168 git grep DOCS_AND_CUSTOM_FREQS: in lucene/core/src/java only IndexOptions, FieldInfo and the Lucene94 field-infos format; elsewhere the Lucene90 field-infos format (backward-codecs), the highlighter, luke and the test-framework; no class under lucene/core/src/java/org/apache/lucene/document sets it
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/CharTermAttributeImpl.java:92-93, (at 5adff78bb168) util/UnicodeUtil.java:199-240 (UTF16toUTF8 replaces unpaired surrogates); analysis/tokenattributes/BytesTermAttribute.java:21-23, TermToBytesRefAttribute.java:46-47
Why it looks unintended
  • lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:106-135 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/IndexOptions.java:24, (at 5adff78bb168) 52-56; index/Term.java:104-122; util/BytesRef.java:133-136; document/Field.java:187-226; index/IndexingChain.java:2076-2097
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/Term.java:104-122; (at 5adff78bb168) util/BytesRef.java:145-157; index/IndexingChain.java:2095-2097
History
  • lucene/CHANGES.txt:957 (at 5adff78bb168)
Diagrams
  • lucene/core/src/java/org/apache/lucene/index/TermsHashPerField.java:200-204; (at 5adff78bb168) index/IndexingChain.java:2074-2100

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

Repro files

About the diagrams

The diagram illustrates the repro's document and the control with an ASCII term (main 5adff78bb168, TermsHashPerField.java:200-204, IndexingChain.java:2074-2100).

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.