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.
- 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. - 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.
- 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 aBytesRefHash, which has the same 32,766-byte limit for its keys.
- Open an
IndexWriterwith default settings, add one document (id:1), then calldeleteDocuments(new Term("id", t))wheretis 32,767 bytes, one byte over the 32,766-byte term limit. - 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. - Instead the delete throws
MaxBytesLengthExceededExceptionafter it was already linked into the writer's shared delete queue, and nothing takes it out again. - Later steps re-apply it and fail: the next
addDocumentand any other delete throw the same exception, andcommit()throwsIllegalStateException: Can't close queue unless all changes are applied, which leaves the writer open (with assertions on, anAssertionErrorthat closes the writer). - 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.
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.
- Queued before it is checked.
deleteDocuments(Term...)(IndexWriter.java:1864-1872) reachesDocumentsWriterDeleteQueue.addDelete, which doeslong 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. - The apply throws.
tryApplyGlobalSlice(:198-215) applies the global slice;DeleteSlice.apply(:348-367) calls each entry'sapply;TermArrayNode.apply(:480-485) callsBufferedUpdates.addTerm, which ends indeleteTerms.put(term, docIDUpto)(BufferedUpdates.java:131) ->DeletedTerms.put(:210-221) ->BytesRefHash.add(:325-327) ->BytesRefBlockPool.addBytesRef, which throwsMaxBytesLengthExceededException: bytes can be at most 32766 in length; got 32767(BytesRefBlockPool.java:83-90). - Never dropped.
DeleteSlice.applycallsreset()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. - Later calls. The next
addDocumentapplies its thread's slice inDocumentsWriterPerThread.finishDocuments(:380-383) and throws; the next delete orupdateDocumentapplies the global slice again (DocumentsWriterDeleteQueue.java:131,:168->tryApplyGlobalSlice,:209) and throws. - 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. Thefinallythen callsclose()on the old queue (DocumentsWriter.java:704-708), which sees unapplied changes and throwsIllegalStateException: 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 anAssertionError("Can not replace a full flush queue if the queue is not closed",DocumentsWriter.java:647-649), whichprepareCommitInternaltreats as tragic (IndexWriter.java:3879-3881), and the writer closes itself.
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).
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, ...)orupdateDocuments(Term, ...)with a term of at least 32,767 bytes. Default codec andIndexWriterConfig, any directory (reproduced onByteBuffersDirectory,FSDirectoryand the test framework's directory); one thread. In the monitor module,Monitor.deleteByIdpasses the id straight todeleteDocuments(new Term(query_id, id)): afterdeleteByIdof a 32,767-character id the nextregister()andMonitor.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 afterrollback()andDirectory.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:softUpdateDocumentwith the 32,767-byte term and thencommit()both succeed);deleteDocuments(Query...)with aTermQueryof 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.
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.
/*
* 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());
}
}
}
}
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()throwsIllegalStateException: Can't close queue unless all changes are applied(assertions off) orAssertionError: pendingNumDocs 1 != 0 totalMaxDoc(assertions on).
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; every release 9.9.0-10.6.0: fails; 9.8.0: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
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
# 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- The delete and update APIs state no length limit. On indexed terms Lucene enforces it with a clean
IllegalArgumentException, andtestWickedLongTermasserts 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 onlyCorruptIndexExceptionandIOException(IndexWriter.java:1855-1863);updateDocumentlikewise (: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). ARuntimeExceptionfrom 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 sameHashMapbuffer). - 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_SIZEandMaxBytesLengthExceededException);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.
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.
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
addDocumentuses for an immense term; the writer stays usable, astestWickedLongTermexpects 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
MaxBytesLengthExceededExceptionseeIllegalArgumentExceptioninstead. 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:testwith the fix, assertions on: the minimal test plusTestIndexWriterDelete,TestBufferedUpdates,TestDocumentsWriterDeleteQueue,TestIndexWriter,TestIndexWriterExceptionsandTestExceedMaxTermLength: 197 tests in 7 suites, 10 skipped, 0 failures. checkGoogleJavaFormatreports 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.
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.
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.
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.
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.
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
-earun. - #12573 (the introducing PR) and its follow-ups #12586, #12630 and #12784 never mention term length. #14261 (open) is an
OutOfMemoryErrorfrom an entry's apply, anErrorand 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.
| Where | When | How | Found |
|---|---|---|---|
| a scripted prior-report search (GitHub issues/PRs, commits, Solr, Elasticsearch, OpenSearch, mailing lists, JIRA) | 2026-10-29 | two runs, 102 queries (symptoms, queue mechanism, introducing change, buffers), plus the finder's 51-query run | 1,471 + 292 distinct hits, 0 high; no report |
| GitHub, apache/lucene | 2026-10-29 | 32 manual searches: the exact messages, MaxBytesLengthExceededException with delete or update, DeleteSlice, TermArrayNode, tryApplyGlobalSlice, DeletedTerms, MAX_TERM_LENGTH; all items updated since 2026-10-27 | no report |
| GitHub, other repositories and all of GitHub | 2026-10-29 | 15 searches for the exact messages and class plus exception pairs | "Can't close queue unless all changes are applied": 0 hits anywhere |
| Open PRs | 2026-10-29 | changed files of all 409 open PRs; the 24 that touch the fix-site files grepped for a term-length check or changed node handling | none; #16867 is related work (different trigger) |
| JIRA archive and mailing lists | 2026-10-29 | 10 JQL queries; 15 exact-phrase searches on dev@, java-user@ and issues@ | only add-side immense-term items and LUCENE-9621 |
| Web | 2026-10-29 | 9 queries | only add-side questions about immense terms |
| Refresh | 2026-10-29 | apache/lucene items updated since a recent cutoff; the message searches again; #16867 state; commits on main to the fix-site files since 2026-10-20 | nothing new; not fixed |
| Refresh | 2026-10-30 | issue search for MaxBytesLengthExceededException; the last commits on the fix-site files on main, branch_11x and branch_10_6 | nothing new; not fixed |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails through gradle and the standalone runner, assertions on and off |
10.6.0 4ae946e5a9b (released 2026-10-29) | affected | the 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) | affected | fail 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) | affected | fail 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/B | fails with the same messages as 9.9.0, assertions on and off |
| 9.8.0, 9.7.0 | not affected | pass (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 affected | the test passes; 197 tests of seven core index suites pass with each fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, then probes) | candidate | The 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 | real | Own 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 | real | No 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. |
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.
- PNG 593 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,340 words
- 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 of645d4fe14a20with 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).
- 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-3881a 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-485a 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-90a 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-196a 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, :4189a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/Term.java:47-48a 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
- TestOverlongDeleteTermKeepsWriterUsable.java (one test, public API):
repro/LFV-1046/TestOverlongDeleteTermKeepsWriterUsable.java(45 lines)
Made in parallel with this doc, from the code at 645d4fe14a20 and the executed repro and step probes.
assets/LFV-1046/bug.png(593 KB)
