All findings · Lucene finding · LFV-1046 · overlong-delete-term-poisons-delete-queue

A rejected over-long delete term stays in IndexWriter's delete queue, so later adds, deletes and the next commit fail

Since 9.9.0, a deleteDocuments or updateDocument call with a term over 32,766 bytes throws but stays in IndexWriter's delete queue: later adds and deletes and the next commit fail, and the documents buffered before it are lost. Up to 9.8.0 the same call was a harmless no-op.

Summary

Priority
Medium, rank 18A released 9.9.0 regression on the default IndexWriter path where one delete or update term over 32,766 bytes leaves the writer unusable until rollback() and drops buffered documents; the trigger is rare and the failure is loud, but the lost buffered documents on the default path keep it in Medium.
Impact
index unusable
Since
9.9.0 (#12573, merged 2023-09-22)introduced 2023-09-22 · about 3 years
Affected
every release from 9.9.0 to 10.6.0 (23 releases) and main; 9.8.0 is clean
Status
documented
Found by
code reading of the delete path, then a one-test 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

IndexWriter queues a delete first and applies it second. Since 9.9.0 the apply can reject an over-long term, but the delete is already in the shared queue, and the queue drops entries only after they applied without error, so the same entry fails again on later adds and deletes and the next commit.

Background

  1. Buffered deletes. deleteDocuments(term) does not touch the index right away. It appends an entry to the writer's shared delete queue; a global buffer and each indexing thread later copy the entries they have not seen yet into their own buffer of pending deletes.
  2. Slices. Each reader of the queue keeps a slice, from the last entry it copied to the newest one. It moves the start of its slice forward only after it copied every entry in the slice without an error.
  3. The term limit. No indexed term can be longer than IndexWriter.MAX_TERM_LENGTH, 32,766 bytes. Since 9.9.0 (#12573) the buffer of pending delete terms is a BytesRefHash, which has the same 32,766-byte limit for its keys.
IndexWriter's shared delete queue: a delete by a 32,767-byte term is linked into the queue, then rejected while it is applied (MaxBytesLengthExceededException); it stays in the queue, so the next addDocument, the next delete and commit() apply it again and fail, and the documents buffered before it are lost.
One rejected delete stays in the writer's shared delete queue, and later adds, deletes and the next commit run into it again. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Open an IndexWriter with default settings, add one document (id:1), then call deleteDocuments(new Term("id", t)) where t is 32,767 bytes, one byte over the 32,766-byte term limit.
  2. You expect that delete to match nothing (no indexed term can be that long; up to 9.8.0 it was a no-op) or to be rejected on its own, and commit() to store the 1 document.
  3. Instead the delete throws MaxBytesLengthExceededException after it was already linked into the writer's shared delete queue, and nothing takes it out again.
  4. Later steps re-apply it and fail: the next addDocument and any other delete throw the same exception, and commit() throws IllegalStateException: Can't close queue unless all changes are applied, which leaves the writer open (with assertions on, an AssertionError that closes the writer).
  5. The documents buffered before the bad delete are lost either way: rollback() discards everything since the last commit, and a commit retried after the failed one succeeds but leaves them out.

Who is affected

Any application on 9.9.0 to 10.6.0 or main that deletes or updates by a term it does not length-check, such as an id taken from a request, through deleteDocuments(Term...), updateDocument(s)(Term, ...) or the monitor module's deleteById. Default settings; one call is enough. Committed data stays intact.

Mechanism, terms and numbers702 words

IndexWriter.deleteDocuments(Term...), updateDocument(Term, ...) and updateDocuments(Term, ...) link the delete into the writer's shared delete queue before they apply it. Since #12573 (9.9.0) applying it stores the term in a BytesRefHash, which rejects keys over 32,766 bytes, the same limit IndexWriter.MAX_TERM_LENGTH puts on indexed terms. A delete by a 32,767-byte term therefore throws MaxBytesLengthExceededException after it is queued, and because the queue's slices are reset only after a fully successful apply, the bad entry is applied again, and fails again, by the next addDocument, delete, commit, NRT reopen or close(). The documents buffered before it are lost: rollback() discards everything since the last commit, and a commit retried after the failed one succeeds without them; committed data stays intact. Up to 9.8.0 the same call was a no-op, and on main the same term as deleteDocuments(new TermQuery(term)) still is. Default settings, one call, one thread; every release from 9.9.0 to 10.6.0 and main are affected.

The code (main 645d4fe14a20)

  1. Queued before it is checked. deleteDocuments(Term...) (IndexWriter.java:1864-1872) reaches DocumentsWriterDeleteQueue.addDelete, which does long seqNo = add(new TermArrayNode(terms)); tryApplyGlobalSlice(); (DocumentsWriterDeleteQueue.java:129-133): the entry is linked at the tail of the shared queue and gets a sequence number first.
  2. The apply throws. tryApplyGlobalSlice (:198-215) applies the global slice; DeleteSlice.apply (:348-367) calls each entry's apply; TermArrayNode.apply (:480-485) calls BufferedUpdates.addTerm, which ends in deleteTerms.put(term, docIDUpto) (BufferedUpdates.java:131) -> DeletedTerms.put (:210-221) -> BytesRefHash.add (:325-327) -> BytesRefBlockPool.addBytesRef, which throws MaxBytesLengthExceededException: bytes can be at most 32766 in length; got 32767 (BytesRefBlockPool.java:83-90).
  3. Never dropped. DeleteSlice.apply calls reset() only after its loop finished (DocumentsWriterDeleteQueue.java:366). The exception skips it, so the global slice, and every per-thread slice that later reaches the entry, starts before it again next time and throws at the same entry.
  4. Later calls. The next addDocument applies its thread's slice in DocumentsWriterPerThread.finishDocuments (:380-383) and throws; the next delete or updateDocument applies the global slice again (DocumentsWriterDeleteQueue.java:131, :168 -> tryApplyGlobalSlice, :209) and throws.
  5. Commit. DocumentsWriter.flushAllThreads (:667-715) swaps in a new queue and flushes. DocumentsWriterPerThread.prepareFlush (:427-438) first extends the thread's slice to the queue's tail (DocumentsWriterDeleteQueue.java:229-232; the global slice is not applied again there, since the failed apply already set its tail to the bad entry, :264, :296-300) and then applies that slice (DocumentsWriterPerThread.java:434), which reaches the bad entry and throws. The finally then calls close() on the old queue (DocumentsWriter.java:704-708), which sees unapplied changes and throws IllegalStateException: Can't close queue unless all changes are applied (DocumentsWriterDeleteQueue.java:317-321), replacing the original exception; with assertions off the writer stays open. With assertions on, the commit ends in an AssertionError ("Can not replace a full flush queue if the queue is not closed", DocumentsWriter.java:647-649), which prepareCommitInternal treats as tragic (IndexWriter.java:3879-3881), and the writer closes itself.

What a probe shows (assertions off, plain program)

Document a committed, b added, then the 32,767-byte delete: the delete and addDocument(c) throw MaxBytesLengthExceededException, an NRT reopen throws the IllegalStateException, and after that the old queue has been dropped, so commit() and close() return normally. The committed index holds 1 document, a; b is lost (c was rejected), and hasUncommittedChanges() stays true. With nothing committed before, 0 of 3. The same at the 10.6.0 release commit. On 9.8.0 the same sequence commits all 3. close() right after the bad delete throws the same IllegalStateException and closes the writer without committing b (1 of 2).

All runs were single-threaded. The global apply runs only when its lock is free (DocumentsWriterDeleteQueue.java:199), so with concurrent writers the bad call itself may return normally and a later call, or the next commit, hit the entry instead (by code reading).

Why 9.8.0 was fine

Up to 9.8.0 BufferedUpdates kept delete terms in a HashMap<Term, Integer> with no key-length limit, so applying a term entry could not throw for this reason. The queue behaviour (append before apply; reset only after a full apply) is from 2011 (LUCENE-2956) and 2016; #12573 gave a legal public call a way to make that step throw.

Exposure and likelihood285 words
  • Trigger. One call of deleteDocuments(Term...), updateDocument(Term, ...) or updateDocuments(Term, ...) with a term of at least 32,767 bytes. Default codec and IndexWriterConfig, any directory (reproduced on ByteBuffersDirectory, FSDirectory and the test framework's directory); one thread. In the monitor module, Monitor.deleteById passes the id straight to deleteDocuments(new Term(query_id, id)): after deleteById of a 32,767-character id the next register() and Monitor.close() throw on main, while 9.8.0 registers both queries.
  • Effect. Later adds and deletes throw and the next commit, NRT reopen or close() fails. The documents buffered before the bad delete are lost: rollback() discards all uncommitted work, and a commit retried after the failed one succeeds without them (after that failed commit the writer works again). The failure is loud (every affected call throws). With assertions off the writer is not marked tragic; with assertions on the failed commit closes it. Committed data is not damaged: the committed index opens and a new writer opens on it in every probe. A secondary consequence: the dropped per-thread writer keeps its 4 stored-fields files open, still after rollback() and Directory.close().
  • Not affected. The doc-values update APIs (updateNumericDocValue, updateBinaryDocValue, updateDocValues; executed), whose buffer has no such key limit; softUpdateDocument(s), which uses that same buffer (executed: softUpdateDocument with the 32,767-byte term and then commit() both succeed); deleteDocuments(Query...) with a TermQuery of the same term (executed: a no-op).
  • Versions. Every release from 9.9.0 to 10.6.0, and main.
  • Likelihood: rare. Real ids are never that long, so it takes an unvalidated oversized key; but a single such call breaks the writer's next adds, deletes and commit and loses its buffered documents, for every client of that writer, not just the bad request.

Minimal repro

One JUnit test on the public API with the default IndexWriterConfig: add 1 document, delete by a 32,767-byte term (the test lets that call throw), commit, and expect 1 document. Deterministic and single-threaded; it fails with assertions on and off.

TestOverlongDeleteTermKeepsWriterUsable.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.StringField;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestOverlongDeleteTermKeepsWriterUsable extends LuceneTestCase {

  public void testCommitAfterOverlongDeleteTerm() throws Exception {
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new StringField("id", "1", Field.Store.NO));
      w.addDocument(doc);
      try {
        w.deleteDocuments(new Term("id", new BytesRef(new byte[IndexWriter.MAX_TERM_LENGTH + 1])));
      } catch (RuntimeException rejected) {
      }
      // Expected: commit succeeds with 1 doc; actual: commit throws, the failed delete stays queued.
      w.commit();
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        assertEquals(1, r.numDocs());
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestOverlongDeleteTermKeepsWriterUsable -Ptests.seed=DEADBEEF
Expected
commit() succeeds and the index holds 1 document, as on 9.8.0.
Observed
commit() throws IllegalStateException: Can't close queue unless all changes are applied (assertions off) or AssertionError: pendingNumDocs 1 != 0 totalMaxDoc (assertions on).

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

More files, environment, full output323 words

The test sits in Lucene's core index test package. The input is fixed. Through gradle the seed also decides whether assertions are on unless -Ptests.asserts is given (on for about 75% of seeds; DEADBEEF turns them on); otherwise it picks only the test framework's random directory wrapper, locale and codec settings, which the test does not depend on. The bug also reproduces without the test framework, in plain programs on ByteBuffersDirectory and FSDirectory.

Through the test framework's directory wrapper the failure also carries a suppressed note that 4 stored-field files of the dropped per-thread writer are still open. The wrapper only detects this: a plain FSDirectory program shows the same 4 descriptors still open after rollback() and Directory.close(). A secondary consequence, not the headline.

Regression test: this same test; it passes with either fix below. Before it goes into a PR, ./gradlew tidy wraps its one comment line (101 characters), which checkGoogleJavaFormat flags.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; every release 9.9.0-10.6.0: fails; 9.8.0: passes
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/TestOverlongDeleteTermKeepsWriterUsable.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestOverlongDeleteTermKeepsWriterUsable -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false

Observed output

# main 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestOverlongDeleteTermKeepsWriterUsable > testCommitAfterOverlongDeleteTerm FAILED
    java.lang.IllegalStateException: Can't close queue unless all changes are applied
        at org.apache.lucene.index.DocumentsWriterDeleteQueue.close(DocumentsWriterDeleteQueue.java:321)
        at org.apache.lucene.index.DocumentsWriter.flushAllThreads(DocumentsWriter.java:707)
        at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3809)
        at org.apache.lucene.index.IndexWriter.commitInternal(IndexWriter.java:4253)
        at org.apache.lucene.index.IndexWriter.commit(IndexWriter.java:4215)
        at org.apache.lucene.index.TestOverlongDeleteTermKeepsWriterUsable.testCommitAfterOverlongDeleteTerm(TestOverlongDeleteTermKeepsWriterUsable.java:39)

# main 645d4fe14a20, gradle, -Ptests.seed=DEADBEEF (assertions on)
TestOverlongDeleteTermKeepsWriterUsable > testCommitAfterOverlongDeleteTerm FAILED
    java.lang.AssertionError: pendingNumDocs 1 != 0 totalMaxDoc
        at org.apache.lucene.index.IndexWriter.rollbackInternal(IndexWriter.java:2596)
        at org.apache.lucene.index.IndexWriter.maybeCloseOnTragicEvent(IndexWriter.java:6104)
        at org.apache.lucene.index.IndexWriter.tragicEvent(IndexWriter.java:6094)
        at org.apache.lucene.index.IndexWriter.prepareCommitInternal(IndexWriter.java:3880)
        ...

# plain step probe, main, assertions off (9.8.0: every step ok, 3 documents committed)
  deleteDocuments(huge): threw MaxBytesLengthExceededException: bytes can be at most 32766 in length; got 32767
  addDocument(c): threw MaxBytesLengthExceededException: bytes can be at most 32766 in length; got 32767
  getReader: threw IllegalStateException: Can't close queue unless all changes are applied
  flush: ok
  commit: ok
  isOpen=true tragic=null hasUncommittedChanges=true
    committed numDocs=1

Why it looks unintended

  • The delete and update APIs state no length limit. On indexed terms Lucene enforces it with a clean IllegalArgumentException, and testWickedLongTerm asserts the writer stays usable.
  • Up to 9.8.0 the same call was a no-op; on main, deleteDocuments(new TermQuery(term)) with the same term still is.
  • It came from #12573, a sort-speed optimization whose review never discusses term length.
The full argument262 words
  • No precondition. deleteDocuments(Term...): "Deletes the document(s) containing any of the terms", documenting only CorruptIndexException and IOException (IndexWriter.java:1855-1863); updateDocument likewise (:1901-1911). Term's constructors call only a null field or null bytes "undefined behavior" (Term.java:47-48).
  • Where the limit is documented, the rejection is contained. MAX_TERM_LENGTH: "If a term arrives from the analyzer longer than this length, an IllegalArgumentException is thrown" (IndexWriter.java:252-258); addDocument: "each term in the document can be no longer than MAX_TERM_LENGTH in bytes, otherwise an IllegalArgumentException will be thrown" (:1480-1481). TestIndexWriter#testWickedLongTerm, after that rejection: "Make sure we can add another normal document" (TestIndexWriter.java:1548-1565).
  • The writer's failure contract. "if you hit an Error, or disaster strikes during a checkpoint then IndexWriter will close itself" (IndexWriter.java:162-165). A RuntimeException from one call is neither, yet here it makes later, unrelated calls fail. commit(): "Commits all pending changes (added and deleted documents, ...)" (:4189).
  • Consistency. The same term as deleteDocuments(new TermQuery(term)) is a no-op on main, and the same call is a no-op on 9.7.0 and 9.8.0 (all executed; earlier releases use the same HashMap buffer).
  • History. #12573 ("Use radix sort to speed up the sorting of deleted terms") is listed under 9.9.0 "Optimizations"; its description covers sort time and memory, and its review never mentions term length. We found no test in Lucene's test sources that deletes or updates by a term over 32,766 bytes (searched for MAX_TERM_LENGTH, BYTE_BLOCK_SIZE and MaxBytesLengthExceededException); TestBufferedUpdates#testDeletedTerms, added by #12573, uses keys of 1-3 bytes.
The devil's-advocate check198 words

The strongest case: 32,766 bytes is "the absolute hard maximum length for a term" and Lucene's SECURITY.md makes length limits on untrusted input the application's job, so a 32,767-byte delete term is garbage in. And continuing after a failed commit can lose documents, with rollback() as the remedy (as Simon Willnauer said on LUCENE-8692, #9738), so the later loss is not a bug of its own. Lucene also documents no general promise that a call that throws leaves the writer unchanged.

Why it fails: no delete or update API states the limit, and where the limit applies Lucene rejects the call cleanly and keeps the writer usable. Here the bad call is itself rejected, yet unrelated later calls (a normal addDocument, a 3-byte delete) fail too, which breaks failure atomicity rather than being garbage in, garbage out. The same term through TermQuery, and through this API up to 9.8.0, is harmless, and the change came from a performance PR that never considered term length. The LUCENE-8692 point covers only the secondary symptom. Without a general failure-atomicity promise, the specific contracts still apply: the add path shows what Lucene does at the same limit. Verdict: not intended.

Suggested fix

Recommended Reject the term with IllegalArgumentException before it is queued (validated)

Reject a delete or update term over 32,766 bytes with an IllegalArgumentException before it enters the delete queue (+21 lines in DocumentsWriterDeleteQueue), as addDocument does for an immense term. The repro and 197 tests of seven core index suites pass with it. Skipping such a term instead, which restores 9.8.0's no-op, passes the same tests.

private static void checkDeleteTerm(Term term) {
  if (term.bytes().length > IndexWriter.MAX_TERM_LENGTH) {
    throw new IllegalArgumentException(
        "delete term in field=\"" + term.field() + "\" is longer than "
            + IndexWriter.MAX_TERM_LENGTH + " bytes: " + term.bytes().length);
  }
}
// called first in addDelete(Term... terms), for each term, and in newNode(Term term)
All options and trade-offs3 options ยท 624 words

Options B and A were built and run on 645d4fe14a20; C is an untested, more general alternative.

Reject the term with IllegalArgumentException before it is queued (validated) (recommended)

In DocumentsWriterDeleteQueue, check every term in addDelete(Term...) and in newNode(Term) (which builds the delete entry of all updateDocument(s)(Term, ...) overloads) before the entry is created, so the call fails on its own and nothing is queued. The check could equally sit in the four public IndexWriter methods; this class is the one place they all pass through.

private static void checkDeleteTerm(Term term) {
  if (term.bytes().length > IndexWriter.MAX_TERM_LENGTH) {
    throw new IllegalArgumentException(
        "delete term in field=\"" + term.field() + "\" is longer than "
            + IndexWriter.MAX_TERM_LENGTH + " bytes: " + term.bytes().length);
  }
}
// called first in addDelete(Term... terms), for each term, and in newNode(Term term)

Trade-offs

  • Same exception type addDocument uses for an immense term; the writer stays usable, as testWickedLongTerm expects on the add path.
  • A behaviour change against 9.8.0, which accepted such a delete silently (9.9.0 and later already throw, just with the wrong consequences); callers that catch MaxBytesLengthExceededException see IllegalArgumentException instead.
  • updateDocument(longTerm, doc) now rejects the whole update, document included, which matches the update's atomic contract. The javadoc of the delete and update methods should then state the limit.

What the checks validated

  • Scratch worktree of 645d4fe14a20, JDK 25: the minimal test fails on stock through gradle with assertions on and off, and passes with the fix, assertions on and off.
  • Gradle :lucene:core:test with the fix, assertions on: the minimal test plus TestIndexWriterDelete, TestBufferedUpdates, TestDocumentsWriterDeleteQueue, TestIndexWriter, TestIndexWriterExceptions and TestExceedMaxTermLength: 197 tests in 7 suites, 10 skipped, 0 failures.
  • checkGoogleJavaFormat reports no violation in the changed file.
  • An independent fact check re-applied the diff and re-ran the minimal test (assertions off) and the 7 suites: pass, 197 tests, 10 skipped.
  • Not run: the full core suite, other modules, nightly tests, ecjLint and forbiddenApis.

Skip the term in BufferedUpdates.addTerm (validated)

In BufferedUpdates.addTerm, return early when term.bytes().length > ByteBlockPool.BYTE_BLOCK_SIZE - 2: such a delete can match no indexed term (+6 lines, with a comment).

public void addTerm(Term term, int docIDUpto) {
  if (term.bytes().length > ByteBlockPool.BYTE_BLOCK_SIZE - 2) {
    // No indexed term can be longer than IndexWriter.MAX_TERM_LENGTH, so this delete matches
    // nothing; skip it instead of failing in the BytesRefHash, which would leave the node in
    // the shared delete queue and fail every later apply.
    return;
  }
  ...

Trade-offs

  • Restores the 9.8.0 outcome (the delete is a no-op), at the place the limit lives, for every path that buffers a delete term; consistent with deleteDocuments(new TermQuery(term)) on main.
  • Silent: a caller passing a bogus key gets no signal.
  • Not the 9.8.0 state: 9.8.0 put the term in its map, counted it and charged its bytes to the RAM accounting; this fix returns before all of that.

What the checks validated

  • The same 7 suites with the fix, assertions on: 197 tests, 10 skipped, 0 failures.
  • The minimal test passes with the fix with assertions on and off (standalone runner; also in an independent reviewer's check).
  • Not run: the full core suite, nightly tests, the format check.

Make DeleteSlice.apply failure-atomic (untested)

Drop or skip an entry whose apply throws instead of keeping it for every later apply. This would also cover other ways an entry can throw, such as the delete-buffer overflow addressed by open PR #16867.

Trade-offs

Not built. The entry is shared by the global slice and every per-thread slice, a multi-term entry may have applied some terms before it throws, and simply resetting the slice on a throw would silently drop the deletes of the entries after it. It needs a design discussion; A or B fixes this trigger without it.

Downstream note

A service that deletes or updates by an id taken unchecked from a client request lets one id over 32,766 bytes make that writer's next adds, deletes and commit fail and lose its buffered documents; capping ids at 32,766 bytes avoids it.

How it was found

Code reading while reviewing how Lucene handles maximum-length terms: the buffer of delete terms has a 32,766-byte key limit that is checked only after the delete is queued. A step-by-step probe confirmed it on main, the 10.6.0 release commit (then its RC1), 10.5.2, 9.9.0 and 9.8.0; it was then reduced to one JUnit test and checked by independent reviewers.

The method in full143 words

Method: code reading, then execution. A hunt unit on maximum-length terms was working through the operation "deleteDocuments(Term...) on long terms" and noticed that since #12573 the delete-term buffer is a BytesRefHash with a 32,766-byte key limit, applied only after the delete is enqueued.

  • A plain step probe (add, commit, add, over-long delete, add, NRT reopen, flush, commit, close) on main, the 10.6.0 release commit (then its RC1), 10.5.2, 9.9.0 and 9.8.0 showed the later failures and the lost buffered documents, with 9.8.0 clean; two more probes covered close() right after the delete and the update APIs.
  • The sequence was then reduced to the one-method JUnit; the history check ran it, in an earlier 50-line version, on every release from 9.7.0 to 10.5.2 and on 9.8.0 with only #12573's change applied; the fact check ran the final test on the 10.6.0 release.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (including the migrated JIRA archive), the mailing lists, Solr, Elasticsearch, OpenSearch or the web, searched 2026-10-29 and re-checked 2026-10-30. Related: open PR #16867, where a different trigger, a 2 GiB buffer overflow, throws at the same place in the delete queue.

Searches8 searches
  • Related, not this bug: open PR #16867 (romseygeek, "Avoid overflows in IndexWriter's delete buffer") and the reports behind it, elastic/elasticsearch#160939 and #160855: the soft-update buffer passes 2 GiB and throws (in #160939's trace inside DeleteSlice.apply). Different buffer, limit and fix site; nobody there discusses a single over-long term or the throwing entry staying in the queue, and #16867 caps the buffer's total size without checking single terms.
  • #10661 (LUCENE-9621, open): matches only the assertion message of the -ea run.
  • #12573 (the introducing PR) and its follow-ups #12586, #12630 and #12784 never mention term length. #14261 (open) is an OutOfMemoryError from an entry's apply, an Error and so tragic; unrelated to term length.
  • Context: #9738 (LUCENE-8692: rollback after a failed commit), #6535 (LUCENE-5472: the add-side immense-term contract), #9857 (LUCENE-8813: origin of the "Can't close queue" check). Add-side immense-term reports (#11052, #8497, #7924) all predate 9.9.0.
  • Gaps: keyword search only; GitHub search tokenizes phrases and does not index PR diffs, so the open PRs were checked by their changed files and diffs.
What was searched, when, and what turned up
WhereWhenHowFound
a scripted prior-report search (GitHub issues/PRs, commits, Solr, Elasticsearch, OpenSearch, mailing lists, JIRA)2026-10-29two runs, 102 queries (symptoms, queue mechanism, introducing change, buffers), plus the finder's 51-query run1,471 + 292 distinct hits, 0 high; no report
GitHub, apache/lucene2026-10-2932 manual searches: the exact messages, MaxBytesLengthExceededException with delete or update, DeleteSlice, TermArrayNode, tryApplyGlobalSlice, DeletedTerms, MAX_TERM_LENGTH; all items updated since 2026-10-27no report
GitHub, other repositories and all of GitHub2026-10-2915 searches for the exact messages and class plus exception pairs"Can't close queue unless all changes are applied": 0 hits anywhere
Open PRs2026-10-29changed files of all 409 open PRs; the 24 that touch the fix-site files grepped for a term-length check or changed node handlingnone; #16867 is related work (different trigger)
JIRA archive and mailing lists2026-10-2910 JQL queries; 15 exact-phrase searches on dev@, java-user@ and issues@only add-side immense-term items and LUCENE-9621
Web2026-10-299 queriesonly add-side questions about immense terms
Refresh2026-10-29apache/lucene items updated since a recent cutoff; the message searches again; #16867 state; commits on main to the fix-site files since 2026-10-20nothing new; not fixed
Refresh2026-10-30issue search for MaxBytesLengthExceededException; the last commits on the fix-site files on main, branch_11x and branch_10_6nothing new; not fixed

History

Introduced in 9.9.0 by #12573 (merged 2023-09-22), which moved buffered delete terms into a BytesRefHash with a 32,766-byte key limit. 9.8.0 passes and 9.8.0 plus only that change fails; all 22 releases from 9.9.0 to 10.5.2, the 10.6.0 release and main fail.

Versions tested7 rows

Introduced by #12573 "Use radix sort to speed up the sorting of deleted terms" (gf2121; merged 2023-09-22, approved by jpountz; milestone 9.9.0): 8b84f6c0965 on main and the branch_9x backport d3a3391d225, both authored 2023-09-22. It replaced BufferedUpdates' Map<Term, Integer> delete-term buffer with per-field BytesRefHash maps on a shared ByteBlockPool. The queue behaviour that keeps a failing entry is older (LUCENE-2956, 2011; the sequence-number change, 2016) and was harmless while applying a term entry could not throw. Not fixed upstream on 2026-10-30.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails through gradle and the standalone runner, assertions on and off
10.6.0 4ae946e5a9b (released 2026-10-29) affectedthe minimal test fails on the release jars, assertions on and off; a plain step probe at that commit: the delete, the next addDocument and the NRT reopen fail, and a later commit keeps only the document committed before the bad delete
10.0.0 - 10.5.2 (12 releases) affectedfail with assertions off (IllegalStateException); 10.5.2 also with assertions on (earlier 50-line version of the test)
9.9.0 - 9.12.3 (10 releases) affectedfail with assertions off (earlier 50-line version of the test); 9.9.0 and 9.12.3 also with assertions on (AssertionError: Can not replace a full flush queue if the queue is not closed)
9.8.0 + only #12573's production change commit A/Bfails with the same messages as 9.9.0, assertions on and off
9.8.0, 9.7.0 not affectedpass (9.8.0 with assertions on and off, 9.7.0 off); the delete is a no-op
fixes A and B on 645d4fe14a20 not affectedthe test passes; 197 tests of seven core index suites pass with each fix

Independent checks

An independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6; one executed, one reading only) and an adjudication all rate it real, and each executed check re-ran a test on main that failed. This doc's gradle runs reproduce it and validate two fixes.

Checks and verdicts314 words

Each check ran its own test or re-ran an earlier one on stock main; the controls (9.8.0, a 32,766-byte term, the fixes) pass.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, then probes) candidateThe JUnit fails on main with -ea and -da, on 10.5.2 and 9.9.0, and passes on 9.8.0; plain probes for later adds, close(), the update APIs and the 10.6.0 release commit (then its RC1); fix A passes the repro and 23 tests.
Cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test fails with -ea and -da across 3 seeds; own plain program on ByteBuffersDirectory fails; a 32,766-byte control commits; 9.8.0 passes.
Independent reproducer realOwn test, written from the claim, fails on main, 10.5.2, 9.12.3 and 9.9.0 with -ea and -da and passes on 9.8.0; its step probe shows a later commit without the document added before the bad delete.
Cross-model, intended lens (OpenAI GPT-5.6, reading only) real (medium)The intended-behaviour case is weak: the limit is documented for indexed terms only. A separate run re-ran the repro on main (fail); main fails and fix A passes with -ea and -da.
Intended-behaviour devil's advocate realNo intended-behaviour argument survives; the TermQuery form is a no-op on main; Monitor.deleteById reaches it; 9.8.0 is clean.
Adjudication confirmed (high)Re-ran the finder's test (-da) and the independent reproduction's (-ea) on main with a fresh seed: both fail. Not noise.
Registration check and this doc's runs still reproduces (2026-10-29/30)Standalone runner and gradle on main: fail with -ea and -da; fixes A and B: pass.
Fact checks of this doc (executed, and OpenAI GPT-5.6 reading only) correct after fixes (2026-10-30)Re-ran the gradle command, main and the 10.6.0 jars (fail), probes for later calls, controls and open files, and fix B (pass); the corrections are in this version.

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

Finding record

LFV-1046, snapshot of 2026-11-06

Registry title
A deleteDocuments or updateDocument(s) call with a term over 32,766 bytes is rejected but stays in IndexWriter's shared delete queue, so every later add and delete and the next commit, NRT reopen or close() fail until rollback() (regression in 9.9.0; up to 9.8.0 such a delete was a no-op)
Area
index / IndexWriter buffered delete terms (DocumentsWriterDeleteQueue.DeleteSlice.apply, BufferedUpdates.DeletedTerms)
Priority
Medium, rank 18: A released 9.9.0 regression on the default IndexWriter path where one delete or update term over 32,766 bytes leaves the writer unusable until rollback() and drops buffered documents; the trigger is rare and the failure is loud, but the lost buffered documents on the default path keep it in Medium.
Impact class
index unusable
Impact note
Any application on the default IndexWriterConfig, codec and directory of 9.9.0 through 10.6.0 and main that passes one delete or update term longer than 32,766 bytes to deleteDocuments(Term...), updateDocument(Term, ...) or updateDocuments(Term, ...) (also reached through Monitor.deleteById) loses the use of its writer: later adds and deletes throw and the next commit, NRT reopen or close() fails until rollback(), which discards everything buffered since the last commit; committed data stays intact and no scale is needed.
Summary
You add a document, call deleteDocuments(new Term("id", t)) with t longer than 32,766 bytes, and commit. No indexed term can be that long, so you expect the delete to match nothing (as it did up to 9.8.0) or to be rejected, and the commit to contain the document. Instead the delete throws BytesRefHash.MaxBytesLengthExceededException after it was already queued and is never removed from the queue, so every later addDocument and delete throws the same exception and commit() fails (with assertions off an IllegalStateException 'Can't close queue unless all changes are applied', with -ea an AssertionError that closes the writer); the only clean way out is rollback().
Introduced
8b84f6c0965dda01514c196126ed10e57cb6d01a (apache/lucene#12573 'Use radix sort to speed up the sorting of deleted terms', gf2121, approved by jpountz; branch_9x backport d3a3391d225e737126428669bb54630d6c93298e) [exec: commit-level A/B, 9.8.0 passes and 9.8.0 plus only the backport's production change fails with the same messages as 9.9.0]. It replaced BufferedUpdates' Map<Term,Integer> delete-term buffer (no key-length limit) with per-field BytesRefHash maps on a shared ByteBlockPool, which reject keys over BYTE_BLOCK_SIZE - 2 = 32766 bytes. The queue behaviour that keeps a failing node (append before apply; DeleteSlice.apply resets only after every node applied) dates to LUCENE-2956 (e95097a7c8d, 2011) and 058970e72bd (2016) and did no harm while applying a term node could not throw (2023-09-22 (author date of 8b84f6c0965 on main, 00:33:01 -05:00; PR merged 2023-09-22T05:33:01Z; backport authored 01:22:15 -05:00 the same day))
First affected version
9.9.0
Affected versions
[exec] main 645d4fe14a20 (-ea and -da: the finder, the independent reproduction, the cross-model review, the intended-behaviour check, the adjudication and the registration check), 10.6.0 RC1 4ae946e5a9b (plain probe on the core jar: the bad delete, the next addDocument and the NRT reopen fail, committed numDocs 1 of 3 and 0 of 3), and all 22 releases 9.9.0, 9.9.1, 9.9.2, 9.10.0, 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 and 10.5.0-10.5.2 (-da: IllegalStateException 'Can't close queue unless all changes are applied'; -ea also on 9.9.0, 9.12.3 and 10.5.2). [read] upstream main and branch_11x: no change to BufferedUpdates.java or DocumentsWriterDeleteQueue.java since 645d4fe14a20. Not affected [exec]: 9.8.0 (-ea and -da) and 9.7.0 (-da); earlier releases [read] have the HashMap delete-term buffer. Not new in 10.6
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-29). The prior-report record (verdict new): two scripted prior-report searches (102 queries over GitHub issues and PRs, commits, Solr, Elasticsearch, OpenSearch, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 1,471 + 292 distinct hits, 0 high) plus the finder's 51-query run, 32 manual apache/lucene searches (exact messages, MaxBytesLengthExceededException with delete or update, DeleteSlice, TermArrayNode, tryApplyGlobalSlice, DeletedTerms, MAX_TERM_LENGTH, all items updated since 2026-10-27), 15 cross-repository and all-GitHub searches ('Can't close queue unless all changes are applied' has 0 hits anywhere), the changed files of all 409 open PRs (24 touch the fix-site files; none adds a delete-term length check or changes how a throwing queue node is handled), upstream commits since 2026-09-15 on main, branch_10_6, branch_10x and branch_11x, 10 JQL queries and 15 mailing-list searches. Related, not this bug: #16867 (romseygeek; merged 2026-10-30 as 2813efa40a3 on main and cdfe8aa7829 on branch_11x, not on branch_10_6; caps the total size of the shared delete buffer; no per-term check, no change to DeleteSlice.apply) and the reports behind it, elastic/elasticsearch#160939 and #160855 (a 2 GiB overflow of the soft-update buffer that throws at the same place, inside DeleteSlice.apply; nobody there discusses a single over-long term or the throwing node staying in the queue); open #10661 (LUCENE-9621), whose assertion is where the -ea run ends; #12573 (the introducing PR) and its follow-ups #12586, #12630 and #12784 never mention term length. Refresh (2026-10-29): the only apache/lucene item updated since a recent cutoff is #16950 (unrelated); MaxBytesLengthExceededException, DeleteSlice and 'Can't close queue' searches show only older add-side or unrelated items; #16867 unchanged since a recent cutoff (merged unchanged 2026-10-30); no commit on main to BufferedUpdates.java, DocumentsWriterDeleteQueue.java or IndexWriter.java since 2026-10-20. Not fixed upstream. Tracking issue #16937 does not list it
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle with assertions on and off (2026-10-30) and through a standalone runner (2026-10-29); both fixes were validated in a scratch worktree of 645d4fe14a20 with JDK 25. Releases: 10.6.0 (released 2026-10-29) fails with the minimal test, assertions on and off (2026-10-30); all 22 releases from 9.9.0 to 10.5.2 fail, 9.8.0 with only #12573's production change fails, and 9.7.0 and 9.8.0 pass (Maven Central jars, standalone runner, an earlier 50-line version of the same test).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1855-1873 (at 645d4fe14a20) (deleteDocuments(Term...): javadoc documents only CorruptIndexException and IOException, no term-length check, only an Error is treated as tragic), :252-258 (MAX_TERM_LENGTH: a term from the analyzer longer than this gets an IllegalArgumentException), :162-165 (the writer closes itself only on an Error or a checkpoint disaster), :4189-4212 (commit: 'Commits all pending changes (added and deleted documents, ...)')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:129-133 (at 645d4fe14a20) (addDelete appends the TermArrayNode before tryApplyGlobalSlice), :198-215 (tryApplyGlobalSlice), :348-367 (DeleteSlice.apply calls reset() only after every node applied), :316-321 (close: 'Can't close queue unless all changes are applied')
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:118-131, (at 645d4fe14a20) 210-219, 325-339 (addTerm -> DeletedTerms.put -> BytesRefIntMap.put -> BytesRefHash.add) and lucene/core/src/java/org/apache/lucene/util/BytesRefBlockPool.java:83-90 (MaxBytesLengthExceededException for more than BYTE_BLOCK_SIZE - 2 = 32766 bytes)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:360-389, (at 645d4fe14a20) 427-438 (finishDocuments and prepareFlush apply the per-thread slice that reaches the failing node) and lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:667-715 (flushAllThreads: the finally's flushingDeleteQueue.close() replaces the failure with IllegalStateException)
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:1548-1565 (at 645d4fe14a20) (testWickedLongTerm: after an immense-term rejection, 'Make sure we can add another normal document') and lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:338-341 (deleteQueries -> writer.deleteDocuments(new Term(query_id, id)), reached by Monitor.deleteById)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:252-258, :1855-1873, :3879-3881
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:129-133, :168, :174, :198-215, :229-232, :264, :296-300, :317-321, :348-367, :480-485
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:118-132, :210-221, :325-327; lucene/core/src/java/org/apache/lucene/util/BytesRefBlockPool.java:83-90
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:361-389, :427-438, :434; lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:145-165, :647-649, :667-715
Who is affected
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:338-341; lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:184-196
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1554-1557, :1605-1610, :1674, :1912-1915, :1947
Minimal repro
  • a Lucene main checkout build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248 (tests.asserts defaults from the seed)
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:162-165, :252-258, :1480-1481, :1855-1863, :1901-1911, :4189
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/Term.java:47-48
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:1548-1565
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:129-151, :348-367

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 and the executed repro and step probes.

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.