All findings · Lucene finding · LFV-281 · custom-termfreq-check-closes-indexwriter

A document rejected for its custom term frequency closes the IndexWriter and rolls back uncommitted changes

When addDocument rejects a document because a token's custom term frequency (TermFrequencyAttribute, for example red|2 through DelimitedTermFrequencyTokenFilter) does not fit the field, the rejection also closes the IndexWriter and rolls back everything since the last commit. Every release since 7.0.0 has it.

Summary

Priority
Low, rank 672Like LFV-121, a documented rejection inside an aborting indexing step closes the IndexWriter, but only for opt-in custom term frequencies on a field setting the javadoc rules out, and the failure is loud.
Impact
crash
Since
7.0.0 (LUCENE-7854, 2017)introduced 2017-06-06 · about 9 years
Affected
every release from 7.0.0 to 10.6.0 and main (sample releases of every line run; all tags contain the introducing commit)
Status
documented
Found by
code reading of the term-vector writer, then a public-API JUnit

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

Lucene rejects a custom term frequency that the field cannot index, which is correct. But the check runs while the token is being written into the in-memory index, a step where an exception aborts the in-memory segment and closes the writer. So one rejected document costs everything since the last commit.

Background

  1. Custom term frequencies. Normally Lucene counts how often a term occurs in a document. With TermFrequencyAttribute an analyzer sets the frequency itself; DelimitedTermFrequencyTokenFilter turns red|2 into the term red with frequency 2. The field must index frequencies (DOCS_AND_FREQS).
  2. Per-document rejection. When a document is invalid, for example its offsets go backwards, addDocument throws and only that document is skipped. The writer stays open and earlier buffered documents are kept.
  3. Aborting exceptions. An exception in the middle of writing the in-memory index is "aborting": the in-memory index may be inconsistent, so the writer drops it, closes itself and rolls back to the last commit.
Diagram of the bug: a document with a custom term frequency of 2 reaches IndexingChain's token loop. The offset and position checks run first and would only reject the document. The custom-frequency check runs later, inside termsHashPerField.add, whose exceptions are aborting, so the IndexWriter closes and rolls back to the last commit: 1 committed document instead of 3. With the suggested fix all four checks run before add and only the document is rejected; executed, all four cases keep the writer open.
One rejected token inside the aborting step of the indexing chain, and what the writer loses; then the fix, which checks the token before that step. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. A field indexes term frequencies (DOCS_AND_FREQS, as TermFrequencyAttribute requires) and also stores term vectors with positions. You commit doc 1, add doc 2 (buffered, not committed), then add a document whose only token a has custom frequency 2.
  2. You expect addDocument to reject just that document (IllegalArgumentException: cannot index term vector positions while using custom TermFrequencyAttribute) and the writer to stay open: add doc 3, commit, and the index has 3 documents.
  3. Instead the rejection becomes the writer's tragic exception: the writer closes itself, adding doc 3 throws AlreadyClosedException, and the index has 1 document. Doc 2 is gone.
  4. Why: the frequency check runs inside termsHashPerField.add, and IndexingChain treats every exception from that call as aborting (the in-memory index may be half-written), except two it converts into ordinary rejections.
  5. Term-vector offsets, positions in the postings (any default TextField) and DOCS-only fields behave the same. Checking the frequency before add keeps the writer open.

Who is affected

Applications that index custom term frequencies (their own TermFrequencyAttribute or DelimitedTermFrequencyTokenFilter) into a field that also stores term-vector positions or offsets, indexes positions (any default TextField) or is DOCS-only. Default config, no codec configured, any scale. The trigger depends on the data: a misconfigured field works until the first token with a frequency other than 1.

Mechanism, terms and numbers606 words

Lucene lets analysis set a per-token term frequency through TermFrequencyAttribute, for example with DelimitedTermFrequencyTokenFilter, which turns red|2 into the term red with frequency 2. When the field also stores term-vector positions or offsets, indexes postings positions (any default TextField), or is DOCS-only and the term repeats, addDocument rejects the document, as upstream tests assert. But the four checks run inside termsHashPerField.add, which IndexingChain treats as aborting, so the rejection also becomes the writer's tragic exception: the IndexWriter closes itself, every add, update and delete since the last commit is rolled back, and every later indexing change or commit throws AlreadyClosedException until a new writer is opened. The neighbouring offset and position checks, and the duplicate-term rejection #15584 added for the same feature in the same try, only reject the document. The bug came in with the feature (LUCENE-7854, 7.0.0) and is in every release up to 10.6.0 and on main.

The code (main 645d4fe14a20)

  1. The token loop. IndexingChain.invertTokenStream handles one token at a time. Its position, offset and length checks (IndexingChain.java:1996-2064) throw plain IllegalArgumentExceptions outside the aborting try, so they only reject the document (the comment at :1989-1994 says the same of analyzer exceptions).
  2. The aborting call. IndexingChain.java:2068-2073: "If we hit an exception in here, we abort all buffered documents since the last flush, on the likelihood that the internal state of the terms hash is now corrupt". Then termsHashPerField.add(...) (:2074-2075). Only MaxBytesLengthExceededException (:2076-2094) and #15584's DuplicateTermException (:2095-2097) are converted to non-aborting IllegalArgumentExceptions; everything else reaches catch (Throwable th) { onAbortingException(th); throw th; } (:2098-2101).
  3. Where the checks are. TermsHashPerField.add (TermsHashPerField.java:190-209) adds the term to the hash, then calls newTerm or addTerm of the postings writer and then of the term-vector writer. The four checks sit inside those calls; three of them run after state was changed, the DOCS-only one before anything is written:
    • postings positions: FreqProxTermsWriterPerField.getTermFreq (:208-219, IllegalStateException "cannot index positions while using custom TermFrequencyAttribute"), called from newTerm (:125) and addTerm (:177, :196);
    • DOCS-only: FreqProxTermsWriterPerField.addTerm (:145-152, IllegalStateException "must index term freq while using custom TermFrequencyAttribute"); it runs only when the term is already in the in-memory segment, because newTerm has no such check (:119-122), and it throws at the top of addTerm, before any write (for an existing term bytesHash.add only returns its id);
    • term-vector positions and offsets: TermVectorsConsumerPerField.getTermFreq (:286-307, IllegalArgumentException), called from newTerm (:270) and addTerm (:281).
  4. Abort and close. DocumentsWriterPerThread.maybeAbort (:594-605, called at :310) aborts the in-memory segment and fires the tragic event; IndexWriter.updateDocuments (IndexWriter.java:1625-1642, used by addDocument) then calls maybeCloseOnTragicEvent (:1639), which rolls the writer back (:6098-6106).

The numbers

The study's four-case test commits doc 1, buffers doc 2, adds the rejected document, then adds doc 3 and commits. Expected for every case: tragic=null isOpen=true nextAddAndCommit=ok committedDocs=3. Actual on main, for term-vector positions, term-vector offsets, postings positions and DOCS-only: tragic=<the rejection> isOpen=false nextAddAndCommit=AlreadyClosedException committedDocs=1. Its controls pass: backwards offsets on the same field type keep the writer open with 3 documents, and frequency 3 on a DOCS_AND_FREQS field without term vectors is indexed and reads back 3.

With the shipped filter on a field with term-vector positions, 5 committed and 5 buffered documents, one document red|2 green makes the rejection tragic. On a default TextField, documents without a delimiter index normally for the whole session; the first words|2 closes the writer and the 5 buffered documents are lost.

Exposure and likelihood281 words
  • Who. Users of the opt-in custom-term-frequency feature, through their own TokenStream or the shipped DelimitedTermFrequencyTokenFilter (factory name delimitedTermFrequency), on a field that stores term-vector positions or offsets (allowed by TermFrequencyAttribute's stated precondition; the filter's javadoc, "no positions or offsets", may be read as ruling these out too), indexes postings positions (any default TextField; the filter's javadoc forbids it) or is DOCS-only (the attribute's javadoc asks for DOCS_AND_FREQS).
  • What it costs. One such document closes the writer. Every add, update and delete since the last commit is rolled back: in the study's probe a document deleted after the last commit was live again in the reopened index, and an update was undone. Other threads' buffered documents go too; that follows from the rollback but was not executed (all runs were single-threaded). The application must open a new writer and re-index everything since the last commit.
  • Not affected. The committed index stays consistent and a new writer works; an NRT reader opened earlier keeps its view. FeatureField, the core field that uses TermFrequencyAttribute, has no positions and no term-vector positions or offsets, so it never reaches these checks (code reading).
  • Conditions. Default IndexWriterConfig with no codec configured (the gradle runs used the test framework's randomized Asserting(Lucene104) codec; the core-only probe runs with Lucene's default codec), assertions on and off, ordinary scale, one thread. Data-dependent: with the filter, the first word|N with N != 1 fires it.
  • How likely: rare. It needs the opt-in feature plus a field setting the javadoc rules out or an unusual term-vector choice, and the failure is loud; but a misconfigured field can run for a long time before the first such token.

Minimal repro

One JUnit test, one thread, deterministic, public API: one document whose only token has term frequency 2, on a DOCS_AND_FREQS field with term-vector positions; addDocument must throw, then the writer must have no tragic exception.

TestCustomTermFreqRejectionKeepsWriterOpen.java (one test, public API)45 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.Directory;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestCustomTermFreqRejectionKeepsWriterOpen extends LuceneTestCase {

  public void testTermVectorPositionsRejectionKeepsWriterOpen() throws Exception {
    FieldType ft = new FieldType();
    ft.setIndexOptions(IndexOptions.DOCS_AND_FREQS);
    ft.setStoreTermVectors(true);
    ft.setStoreTermVectorPositions(true);
    Token token = new Token("a", 0, 1);
    token.setTermFrequency(2);
    Document doc = new Document();
    doc.add(new Field("f", new CannedTokenStream(token), ft));
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      expectThrows(IllegalArgumentException.class, () -> w.addDocument(doc));
      // expected: only the document is rejected; actual: its exception closes the writer as tragic
      assertNull("tragic exception", w.getTragicException());
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCustomTermFreqRejectionKeepsWriterOpen -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
addDocument throws IllegalArgumentException for this document only, and getTragicException() is null.
Observed
The same IllegalArgumentException is the writer's tragic exception: AssertionError: tragic exception expected null, with assertions on and off.

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

More files, environment, full output286 words

The field meets TermFrequencyAttribute's precondition (DOCS_AND_FREQS). The token comes from the test framework's CannedTokenStream with one Token whose term frequency is 2; the writer uses the default IndexWriterConfig. The test sits in org.apache.lucene.index, as Lucene's own tests do, but uses no package-private API. Line 40 of the file (expectThrows, counting the license header) passes, so the documented rejection is unchanged; only line 42 fails. No commit or extra documents are needed: the tragic exception alone shows the writer closed, and the rollback of uncommitted work follows from it (shown by the study's longer test, in the collapsed explanation above).

The other three cases (term-vector offsets, postings positions, DOCS-only) share the root cause and were executed by the study's longer test; they are not part of this repro.

Regression test: this same test; it passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on the 10.6.0 and 10.5.2 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
cp $REPRO/TestCustomTermFreqRejectionKeepsWriterOpen.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestCustomTermFreqRejectionKeepsWriterOpen -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
TestCustomTermFreqRejectionKeepsWriterOpen > testTermVectorPositionsRejectionKeepsWriterOpen FAILED
    java.lang.AssertionError: tragic exception expected null, but was:<java.lang.IllegalArgumentException: field "f": cannot index term vector positions while using custom TermFrequencyAttribute>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:EED294BF6AE05155]:0)
        at org.junit.Assert.assertNull(Assert.java:738)
        at org.apache.lucene.index.TestCustomTermFreqRejectionKeepsWriterOpen.testTermVectorPositionsRejectionKeepsWriterOpen(TestCustomTermFreqRejectionKeepsWriterOpen.java:42)
> Task :lucene:core:test FAILED

# the study's four-case test on the same stock build (gradle, seed DEADBEEF): 6 tests completed, 4 failed
testTermVectorPositions  expected:<tragic=[null isOpen=true nextAddAndCommit=ok committedDocs=3]> but was:<tragic=[IllegalArgumentException isOpen=false nextAddAndCommit=AlreadyClosedException committedDocs=1]>
testTermVectorOffsets    same, IllegalArgumentException
testPostingsPositions    same, IllegalStateException
testExtraDocsOnlyRepeatedTerm  same, IllegalStateException

Why it looks unintended

  • #15584's duplicate-term rejection, for the same feature and in the same try, keeps the writer open on purpose ("ensure IndexWriter remains open and doc was skipped").
  • The repro's field meets the documented precondition (DOCS_AND_FREQS); nothing documents that this rejection closes the writer.
  • LUCENE-5662 fixed the same pattern as a bug: missing checks let a null stored value abort the writer.
The full argument297 words
  • Same feature, same try, opposite outcomes. #15584 (10.5.0) added a custom-frequency rejection (a duplicate term on a DOCS_AND_CUSTOM_FREQS field) in this very try and made it non-aborting (IndexingChain.java:2095-2097); TestCustomTermFreq.java:125 says "ensure IndexWriter remains open and doc was skipped". The PR's author, msokolov, wrote in the PR conversation that they "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)". With the same index options (DOCS_AND_CUSTOM_FREQS; the duplicate-term case without term vectors), the duplicate term keeps the writer open with 3 documents committed, while frequency 2 with term-vector positions closes it with 1.
  • The precondition is met. TermFrequencyAttribute asks only for DOCS_AND_FREQS (TermFrequencyAttribute.java:23-27); the DOCS_AND_CUSTOM_FREQS javadoc says nothing about term vectors (IndexOptions.java:52-56). Upstream's own test of the term-vector rejection uses DOCS_AND_CUSTOM_FREQS + term-vector positions (TestCustomTermFreq.java:471-496).
  • Nothing documents the close. The IndexWriter javadoc names "an Error, or disaster strikes during a checkpoint" (IndexWriter.java:162-165); addDocument says after an exception "the index will be consistent, but this document may not have been added" (:1465-1466); LUCENE-5987 describes aborting exceptions as ones "that would otherwise cause silent data loss".
  • Precedent. LUCENE-5662 is listed under "Bug fixes" in 4.8.1; testNullStoredField and testCrazyPositionIncrementGap assert getTragicException() == null after a rejected document, and LUCENE-5611's testNoAbortOnBadTVSettings checks "the exc didn't lose our first document".
  • Why upstream tests never noticed. The four rejection tests end with IOUtils.close(w, dir) and never look at the writer, and close() after a tragic close does nothing (close(), IndexWriter.java:1363-1369, calls shutdown() or rollback(), which stop at shouldClose, :1374-1392). The LUCENE-7854 discussion (34 comments) never mentions aborting.
The devil's-advocate check197 words

The strongest case: this is misuse, because TermFrequencyAttribute requires DOCS_AND_FREQS and DelimitedTermFrequencyTokenFilter says "no positions or offsets"; and every exception from add is aborting on purpose, because the terms hash may be corrupt. The study confirmed that part for the postings-positions check: a shadow that only skips the abort, run on a default TextField, corrupts the in-memory postings (AssertionError with assertions on; docs out of order or cannot write negative vLong at flush with assertions off). It was not run for the term-vector or DOCS-only checks; the DOCS-only check throws before anything is written.

Why it fails: for TermFrequencyAttribute (the repro's API) the misuse argument covers only the postings-positions and DOCS-only cases (it lowers their severity); the term-vector cases are inside the attribute's stated precondition. The filter's javadoc can be read as also ruling out term-vector positions and offsets. The second half explains why the abort is needed once a check fires mid-write, not why a deterministic check of the token against the field type must run there: moving it before add keeps the exception types and messages, keeps the writer open and passes upstream's tests. A maintainer may still label the change an improvement.

Suggested fix

Recommended Validate the custom frequency before termsHashPerField.add (validated)

Check the custom frequency in IndexingChain before termsHashPerField.add: the same four conditions, exception types, messages and DOCS-only rule, thrown before anything is written, so only the document is rejected (+50 lines in two files). The repro, the four-case test, the closest suites and the whole core module pass.

if (invertState.termFreqAttribute.getTermFrequency() != 1) {
  // Check here, not inside termsHashPerField.add below: an exception thrown from there
  // aborts all buffered documents, while this one only rejects the current document.
  validateCustomTermFrequency(field);
}
All options and trade-offs4 options · 842 words

Option A was built and run. B, a simpler variant, was run too and breaks an upstream test. C is the study's earlier, partial fix. D is not recommended.

Validate the custom frequency before termsHashPerField.add (validated) (recommended)

In IndexingChain.invertTokenStream, right after the offset checks (IndexingChain.java:2051), a token whose frequency is not 1 goes through a new validateCustomTermFrequency(field). In the order the old checks fire, it throws IllegalStateException "must index term freq ..." for a DOCS-only field when the term is already buffered (a new TermsHashPerField.containsTerm, bytesHash.find(termBytes) >= 0, true exactly when add would call addTerm), IllegalStateException "cannot index positions ..." when postings have positions, and IllegalArgumentException "cannot index term vector positions / offsets ..." when the field stores term vectors with positions or offsets.

if (invertState.termFreqAttribute.getTermFrequency() != 1) {
  // Check here, not inside termsHashPerField.add below: an exception thrown from there
  // aborts all buffered documents, while this one only rejects the current document.
  validateCustomTermFrequency(field);
}

Trade-offs

  • Old checks. The checks inside add stay; with the fix they are no longer reached from IndexingChain. They could become assertions.
  • Which exception wins. A document with two problems may report a different one first (for example the term-vector check before #15584's duplicate-term check; both reject only the document). It also runs before the field-length overflow check (IndexingChain.java:2053-2064): a term repeated on a DOCS-only field whose frequency would also overflow the field length gets the DOCS-only IllegalStateException instead of "too many tokens"; both reject only the document.
  • Cost. One int comparison per token; for ordinary fields the frequency is already read for the field length (IndexingChain.java:2057-2059), for DOCS_AND_CUSTOM_FREQS fields it is one extra attribute read. The field checks, and for DOCS-only fields one hash lookup, run only for frequencies other than 1. No benchmark was run.
  • Conflicts. Open PR #16269 ("Lazily build the term-vectors per-field", states no behaviour change) also edits IndexingChain.java and TermsHashPerField.java, in other hunks; it leaves the checks and the abort handling unchanged, so at most a textual rebase is needed.

What the checks validated

Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-30):

  • stock: the minimal test fails with assertions on and off; the study's four-case test fails 4 of 6;
  • with the fix: the minimal test passes with assertions on and off; the four-case test passes 6 of 6 with assertions on and off (all four cases keep the writer open with 3 committed documents);
  • the closest suites TestCustomTermFreq, TestTermVectorsWriter, TestTermVectors, TestIndexWriterExceptions and TestFeatureField pass (88 tests, 0 failures, 3 skipped), and so does TestDelimitedTermFrequencyTokenFilter (3 tests);
  • the whole :lucene:core:test passes (748 classes, 8,897 tests, 0 failures, 322 skipped; run after the two copied tests were removed, so it checks upstream core with the patch); checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for core pass.
  • assertions on and off were run for the minimal and the four-case test; the closest suites, the filter's tests and the whole core module ran with assertions on.

Variant: reject a custom frequency on a DOCS-only field at its first occurrence (run, breaks a test)

The same check without the hash lookup (+42 lines, IndexingChain.java only). It matches the attribute's javadoc and the comment "you can't index DOCS_ONLY with custom term freq" (TestCustomTermFreq.java:373).

Trade-offs

Changes behaviour an upstream test relies on: the minimal test, the four-case test and the filter's tests pass, but TestCustomTermFreq.testOverflowInt, which indexes frequencies 3 and Integer.MAX_VALUE on a DOCS field and expects the length-overflow IllegalArgumentException, now gets IllegalStateException: ... must index term freq .... It was the only failure in the org.apache.lucene.index package (1402 tests). Its lint run is incomplete: checkGoogleJavaFormat failed on a formatting nit in the copied four-case test, not in the patch. A stricter DOCS-only rule would be a separate change.

The study's earlier fix: move three of the four checks

Moves the postings-positions and term-vector checks before add, not the DOCS-only check.

Trade-offs

The repro passes, and so do upstream TestCustomTermFreq (15 tests) and the term-vector, exception and addIndexes suites the study ran (56 and 92 tests); but a DOCS-only field with a repeated term still closes the writer (the four-case test fails 1 of 6).

Catch these exceptions as non-aborting (not recommended)

Convert them in the catch chain, as #15584 did for DuplicateTermException.

Trade-offs

Unsafe for the postings-positions check: it fires after the postings were partly written, and the study's shadow that skips the abort corrupts the in-memory postings (AssertionError with assertions on; CorruptIndexException: docs out of order or cannot write negative vLong at flush with assertions off). The term-vector checks also fire after state was written (not tested with a shadow), and moving them to the top of newTerm is not enough, because for a new term the hash and stream slices were already updated. The DOCS-only check throws before anything is written, so a targeted non-aborting catch might be safe there, but that was not tested; checking all four before add avoids the question.

Downstream note

OpenSearch exposes this filter as delimited_term_freq; by code reading only, a tragic writer exception there fails the shard copy.

How it was found

While modelling the term-vector encoding, the finder read TermVectorsConsumerPerField and noticed that its custom-frequency rejection runs inside the aborting add call. A public-API JUnit on the stock jars confirmed that the rejection closes the writer; independent checkers then found the fourth (DOCS-only) case.

The method in full144 words

Method: code reading, then executed public-API tests (the hunt classes it as non-formal). The finder was building a Z3 (SMT) model of the term-vector encoding; the model covers the encoding, not this exception path, and no formal tool witnessed the bug.

  • First test. Commit one document, buffer one, add a document whose token stream sets TermFrequencyAttribute to 3, then add and commit another: all three of its bug cases closed the writer, and its control (offsets going backwards, rejected before add) kept it open.
  • Widening. An independent reproducer, writing its own test from the claim only, found the DOCS-only case; a devil's-advocate verifier showed the data dependence with the shipped filter and that a non-aborting catch would corrupt the postings of a default TextField (postings with positions).
  • The minimal repro above is the term-vector-positions case cut down to one token and one assertion.

Prior reports, history and checks

Prior reports

None found: no issue, PR, list thread or downstream report says a custom-frequency rejection closes the writer (searched 2026-10-22/23, 2026-10-26 and 2026-10-30). Closest: #15584, which made its own new check non-aborting for this reason, and #8905, which introduced the four checks.

Searches3 searches
  • #15584 (merged 2026-04-24): its author found that their new duplicate-term check closed the writer and made it skip the document instead; the body and comments never mention the four older checks.
  • #8905 (LUCENE-7854): introduced the checks; 34 comments, none about aborting or the writer closing.
  • #8917 (LUCENE-7866): documents the filter's no-positions precondition, not what happens when a field breaks it.
  • #16269 (open): refactors the term-vector per-field writer and leaves the checks and abort handling unchanged.
  • Design and precedents: #7049 (LUCENE-5987, aborting exceptions close the writer), #6673 (LUCENE-5611), #6724 (LUCENE-5662), #2189 (LUCENE-1112), #9252 (LUCENE-8205).
  • Downstream: Elasticsearch #28803 and #27552, OpenSearch #9479, Solr: expose or document the filter; none reports the writer closing.
  • Same family in the registry: LFV-121, a float vector field routed to HnswBitVectorsFormat rejected inside an aborting indexing step (different check and fix site).
  • Limits: keyword search; inline review threads and private channels are not fully covered.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch and its docs, Solr)2026-10-30three prior-art runs (messages, mechanism, downstream; 22 GitHub, 3 commit and 2 downstream queries each) and 66 direct searches: the four exception messages, TermFrequencyAttribute, DelimitedTermFrequencyTokenFilter, DOCS_AND_CUSTOM_FREQS, onAbortingException, AlreadyClosedException, tragic; all 408 open apache/lucene PRs, and the changed files of the 407 updated since 2025-01-01related items only (#15584, #8905, #8917, #16269 and the aborting-design issues); none the same bug
Mailing lists (dev@, java-user@, issues@, general@ and the Solr lists), JIRA archive, web2026-10-3020 list queries per run plus 5 direct queries on each of 8 lists; 7 JQL queries; 24 web queriesno report
Earlier searches2026-10-22/23 and 2026-10-26the study's search and the follow-up's five prior-art runs and 35 direct GitHub searchesno report (verdict new)

History

Introduced with the feature itself by d276acfbbcb (LUCENE-7854, 2017-06-06), first released in 7.0.0. Fails on every release line from 7.0.0 to 10.6.0 and on main; 6.6.6 and older lack the API.

Versions tested6 rows

Introduced by d276acfbbcb8ea6124fe486e35cea9eb928e4702, "LUCENE-7854: enable indexing custom term frequencies" (Mike McCandless, 2017-06-06; a JIRA patch, issue migrated to #8905). It created TermFrequencyAttribute and all four checks. In its parent the indexing chain already wrapped the add call as aborting (catch (Throwable th) { throw AbortingException.wrap(th); }), and LUCENE-5987 (5.0.0) had made aborting exceptions close the writer, so the feature never worked any other way. git log -S on the messages finds only this commit; all 66 release tags from 7.0.0 to 10.6.0 contain it. LUCENE-8205 later replaced AbortingException.wrap with onAbortingException, with the same effect.

Not fixed: upstream main 2f424fd428da (2026-10-30) has no later commit to the check sites. #15584 (10.5.0) added the non-aborting duplicate-term rejection beside them.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off (gradle and standalone); all four cases fail; passes with the fix
10.6.0 affectedminimal test fails, assertions on and off; all four cases fail
9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedminimal test or the four-case test fails
7.7.3, 8.0.0, 8.11.4, 9.0.0 affectedthe minimal test (test-framework imports adapted before 9.1.0) or a core-only probe of the cases fails
7.0.0 first affectedminimal test fails, assertions on and off; all four cases fail
6.6.6 and older no such APIthe test does not compile: TermFrequencyAttribute does not exist

Independent checks

Reproduced by an independent reproducer with its own test, a devil's-advocate verifier, an adjudication, a re-verification and a tie-breaker on current main; reviews by OpenAI GPT-5.6 (reading only) split, both agreeing on the root cause. Controls pass, and every fix check passed the repro.

Checks and verdicts325 words

Each check ran its own tests or re-ran an earlier one on stock jars; the cross-model reviewers could only read code.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateThree cases close the writer with assertions on and off; the backwards-offsets control stays open; a filter variant fails; its fix passes the repro and 15 + 56 + 92 upstream tests.
Independent reproducer realOwn test written from the claim only: 4 of 6 fail on main, 10.5.2 and 9.12.3; found the DOCS-only case; both controls pass; the filter test fails.
Works-as-designed lens (OpenAI GPT-5.6, reading only) real (medium)Intended-behaviour argument moderate and rebutted; root cause and fix agree; inputs in contract.
Intended-behaviour devil's advocate realScope probes: DOCS-only, DOCS_AND_CUSTOM_FREQS with offsets, data dependence with the filter; a non-aborting catch corrupts the postings of a default TextField, so the check must move before add.
Adjudication confirmed (high)Re-ran the independent reproduction's test with seed 5EED1234: 4 of 6 fail with assertions on and off, controls pass; the filter test fails.
Re-verification on main 645d4fe14a20 realThe minimal test fails on main and 10.5.2 with assertions on and off; the earlier fix rebased on this main passes it.
Refute lens (OpenAI GPT-5.6, reading only) not real (medium)Could not run code, so it could not reproduce; agreed on the root cause, rated the intended case moderate, and argued misuse and a deliberate abort (answered under "Why it is a bug").
Tie-breaker real (high)Minimal test, four-case test and a core-only probe on main, 10.6.0 and 10.5.2, new seed; the same feature's duplicate-term rejection keeps the writer open; the fix passes.
This doc's fix validation realGradle in a scratch worktree: the minimal test and the four-case test fail on stock main and pass with the recommended fix, as does the whole core module; a stricter variant breaks one upstream test.

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

Finding record

LFV-281, snapshot of 2026-11-06

Registry title
A document rejected for a custom TermFrequencyAttribute on a field with term-vector positions or offsets, postings positions or DOCS-only postings is rejected inside an aborting step of the indexing chain, so it closes the IndexWriter and rolls back every change since the last commit
Area
index / IndexingChain inversion with custom term frequencies (TermVectorsConsumerPerField, FreqProxTermsWriterPerField) x aborting-exception classification
Priority
Low, rank 672: Like LFV-121, a documented rejection inside an aborting indexing step closes the IndexWriter, but only for opt-in custom term frequencies on a field setting the javadoc rules out, and the failure is loud.
Impact class
crash
Impact note
Applications on every release since 7.0.0 and on main that index custom term frequencies (their own TokenStream setting TermFrequencyAttribute, or the shipped DelimitedTermFrequencyTokenFilter) into a field that also stores term-vector positions or offsets, indexes postings positions (any default TextField) or is DOCS-only with a repeated term: the first token with a frequency other than 1 is rejected as documented, but the rejection also closes the IndexWriter as a tragic event, so every add, update and delete since the last commit is rolled back and every later call throws AlreadyClosedException until a new writer is opened; default IndexWriterConfig and codec, -ea and -da, ordinary scale; the committed index stays consistent and a new writer works, and with the shipped filter a misconfigured field indexes normally until the first 'word|N' token with N != 1.
Summary
You index a document whose analysis chain sets a custom term frequency (TermFrequencyAttribute, for example DelimitedTermFrequencyTokenFilter with a token like 'red|2') on a field that also stores term-vector positions or offsets, indexes postings positions (a default TextField), or is DOCS-only and repeats the term. You expect addDocument to reject only that document with its usual IllegalArgumentException or IllegalStateException and the writer to stay open, as it does for the neighbouring offset checks and for the duplicate-term check that #15584 made non-aborting. Instead the four checks run inside termsHashPerField.add, which IndexingChain treats as aborting, so the rejection also becomes the writer's tragic exception: IndexWriter closes itself, the next call throws AlreadyClosedException, and everything since the last commit is lost (the repro ends with 1 committed document instead of 3).
Introduced
d276acfbbcb8ea6124fe486e35cea9eb928e4702, 'LUCENE-7854: enable indexing custom term frequencies' by mikemccand (JIRA-patch workflow, issue migrated to #8905; no GitHub PR), which created TermFrequencyAttribute together with all four rejection checks: TermVectorsConsumerPerField.getTermFreq (term-vector positions, term-vector offsets), FreqProxTermsWriterPerField.getTermFreq (postings positions) and the !hasFreq check in FreqProxTermsWriterPerField.addTerm (DOCS-only, repeated term) [read: git log -S 'while using custom TermFrequencyAttribute' and -S 'must index term freq while using custom' over the full history return only this commit; git blame -w -C -C on the check lines]. All four run inside termsHashPerField.add, which DefaultIndexingChain already wrapped as aborting in the parent commit (catch (Throwable th) { throw AbortingException.wrap(th); }), and LUCENE-5987 (5.0.0) had made aborting exceptions close the writer; LUCENE-8205 later replaced AbortingException.wrap with onAbortingException with identical behaviour [read: git show of DefaultIndexingChain at d276acfbbcb^, 6.6.0, 7.0.0, 7.3.0 and 7.4.0; exec: same failure on 7.0.0 and 7.7.3]. The defect is the placement of the checks, present since the feature was created; isolation is by API absence (the probe cannot compile on 6.6.0 or 6.6.6, TermFrequencyAttribute being the only missing symbol) (2017-06-06 (d276acfbbcb committed; #8905 / LUCENE-7854 opened 2017-05-26, closed 2017-06-06); 7.0.0 tagged 2017-09-08)
First affected version
7.0.0 [exec: all four cases close the writer with -ea and -da, both controls pass]; 6.6.6 and older lack the API [exec: compile error on 6.6.0 and 6.6.6]
Affected versions
every release from 7.0.0 to 10.5.2 and main ea57360912ed (11.0.0-SNAPSHOT) [exec, follow-up history runs with a core-only main() probe covering all four cases plus two controls: 7.0.0 (-ea and -da), 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) fail; the independent reproduction's JUnit fails 4 of 6 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's repro (three cases) fails on 10.5.2, 9.12.3 and main and its core-only variant on 9.0.0, 8.11.2, 8.0.0 and 7.0.0]; every release tag 7.0.0..10.5.2 in the history clone contains d276acfbbcb [read: git tag --contains]; on 10.5.0 and later (#15584) also with the purpose-built IndexOptions.DOCS_AND_CUSTOM_FREQS plus term-vector offsets [exec on main only: the intended-behaviour check probe P2]; branch_10x and branch_10_6 not checked; not affected: 6.6.6 and older (API absent) [exec]; not fixed upstream as of main ea57360912ed (no upstream commit to the four files after it, checked 2026-10-26) [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 could only read the code and voted “not real” while agreeing with the root cause (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 verdict new, read-only: five scripted prior-report searches, three of them with 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and web queries and two with the list and JIRA queries only, 0 errors or truncations; the finder's and the intended-behaviour check's scripted prior-report searches; 35 direct GitHub REST searches over apache/lucene, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr issues, PRs, commits and code; direct lists.apache.org queries on the Lucene and Solr lists; JQL searches; web searches; upstream commits by path for the four files; local git history). Related prior items, none of them this bug: #8905 (LUCENE-7854, introduced the four checks; 34 comments, none about aborting or the writer closing); #15584 (merged 2026-04-24: its author made the PR's own new duplicate-term custom-frequency check non-aborting after it closed the writer, with TestCustomTermFreq#testRepeatedTerms 'ensure IndexWriter remains open', and left these four checks aborting); #8917 (LUCENE-7866, documented the no-positions precondition of DelimitedTermFrequencyTokenFilter); #7049 (LUCENE-5987), #6673 (LUCENE-5611), #6724 (LUCENE-5662), #2189 (LUCENE-1112) and #9252 (LUCENE-8205) (aborting/non-aborting design and precedents); #9990, #11086, #9547 and #9801 (other custom-frequency issues); open #16269 (refactors TermVectorsConsumerPerField, states no behaviour change, may conflict textually with a fix); downstream elastic/elasticsearch#28803 and #27552 and opensearch-project/OpenSearch#9413 and #9479 (expose custom frequencies; mention only that positions are rejected)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, together with the study's four-case test and the closest suites. Also fails on the 10.6.0 and 10.5.2 release jars; the version sweep covers 7.0.0 to 10.6.0. Upstream main 2f424fd428da (2026-10-30) has no later commit to the check sites.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2013-2018 (comment: an exception in add aborts all buffered documents because the terms hash may be corrupt), :2020 (termsHashPerField.add), :2021-2039 (MaxBytesLengthExceededException converted to a non-aborting IllegalArgumentException), :2040-2042 (DuplicateTermException, #15584, non-aborting), :2043-2045 (catch (Throwable th) { onAbortingException(th); throw th; })
  • lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:286-307 (getTermFreq throws IllegalArgumentException for a custom freq with term-vector positions or offsets)
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:118-125 (newTerm: no check when !hasFreq; getTermFreq when hasFreq), :146-152 (addTerm: IllegalStateException 'must index term freq while using custom TermFrequencyAttribute' for DOCS-only), :208-218 (getTermFreq: IllegalStateException for postings positions)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:594-604 (maybeAbort: abort() and onTragicEvent)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165 (class javadoc: closes itself on an Error or a checkpoint disaster; not an exhaustive list) 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/analysis/tokenattributes/TermFrequencyAttribute.java:23-26 (the field 'must be indexed with IndexOptions#DOCS_AND_FREQS'); lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/DelimitedTermFrequencyTokenFilter.java:31-32 ('DOCS_AND_FREQS but no positions or offsets')
  • lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:106-133 (testRepeatedTerms, :125 'ensure IndexWriter remains open and doc was skipped'), :348, :374, :471, :498 (testInvalidProx, testInvalidDocsOnly, testInvalidTermVectorPositions, testInvalidTermVectorOffsets: type and message only, writer state never checked)
  • lucene/core/src/test/org/apache/lucene/index/TestTermVectorsWriter.java:680 (testNoAbortOnBadTVSettings, LUCENE-5611) and TestIndexWriterExceptions.java:1593 (testNullStoredField, LUCENE-5662, assertNull(getTragicException())) and :1785 (testCrazyPositionIncrementGap)
  • lucene/CHANGES.txt:11544-11546 (LUCENE-5987: IndexWriter forcefully closes itself on an aborting exception)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:176-180, (at 645d4fe14a20) :1987-2101 (:1989-1994, :1996-2064, :2068-2073, :2074-2075, :2076-2097, :2098-2101)
  • lucene/core/src/java/org/apache/lucene/index/TermsHashPerField.java:150-178, (at 645d4fe14a20) :190-209, :211-218
  • lucene/core/src/java/org/apache/lucene/index/FreqProxTermsWriterPerField.java:113-138 (at 645d4fe14a20) (:119-122, :125), :141-206 (:145-152, :169-177, :196), :208-219
  • lucene/core/src/java/org/apache/lucene/index/TermVectorsConsumerPerField.java:267-284 (at 645d4fe14a20) (:270, :281), :286-307
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:245-312 (at 645d4fe14a20) (:310), :594-605
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1491-1492, (at 645d4fe14a20) :1912-1913, :1625-1642 (:1639), :6098-6106
Who is affected
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-27 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/DelimitedTermFrequencyTokenFilter.java:29-33; (at 645d4fe14a20) DelimitedTermFrequencyTokenFilterFactory.java:42
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:108-120 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2095-2097; (at 645d4fe14a20) IndexOptions.java:52-56; IndexWriter.java:162-165, :1465-1466, :1363-1369, :1312-1319, :1374-1392
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/TermFrequencyAttribute.java:23-27 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:106-134 (at 645d4fe14a20) (:125), :137-141, :348-371, :373-398, :471-496 (:495), :498
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterExceptions.java:1592-1609, (at 645d4fe14a20) :1785-1811; TestTermVectorsWriter.java:679-706
  • lucene/CHANGES.txt:919 (at 645d4fe14a20) (GITHUB#15584, 10.5.0), :11577-11579 (LUCENE-5987, 5.0.0 Bug Fixes), :12384-12385 (LUCENE-5662, 4.8.1 Bug fixes)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2051, (at 645d4fe14a20) :2057-2059; TermsHashPerField.java:190-209, :339-341; FreqProxTermsWriterPerField.java:119-122, :145-152
  • lucene/core/src/java/org/apache/lucene/util/BytesRefHash.java:347-357 (at 645d4fe14a20) (find)
  • lucene/core/src/test/org/apache/lucene/index/TestCustomTermFreq.java:373-398, (at 645d4fe14a20) :400-429
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2053-2064 (at 645d4fe14a20) (length overflow check, after fix A's insertion point), :2054-2055

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, the four-case test's numbers and the fix diff.

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.