All findings · Lucene finding · LFV-644 · delete-documents-retains-caller-array

IndexWriter.deleteDocuments(Term...) and deleteDocuments(Query...) keep the caller's array, so reusing it after the call deletes the wrong documents

IndexWriter.deleteDocuments keeps the Term[] or Query[] you pass and reads it again later for documents still in RAM, so refilling it for the next batch deletes the wrong documents: deleting 6 ids in 3 batches of 2 leaves 4 of them. Every release since 4.0 is affected.

Summary

Priority
Low, rank 260Core API and durable, but only callers that change a Term[] or Query[] after passing it are hit (no known caller), which maintainers may treat as misuse.
Impact
data corruption
Since
4.0.0-ALPHA (2012; LUCENE-2956, committed 2011)introduced 2011-04-13 · over 15 years
Affected
every release from 4.0.0-ALPHA to 10.5.2, and main
Status
documented
Found by
semi-formal: an argument-ownership table checked by mutate-after-return probes

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

deleteDocuments takes an array of terms or queries. Documents already flushed to a segment are deleted by what the array holds during the call; documents still in the writer's RAM buffer are deleted later, by what it holds then.

Summary card: IndexWriter.deleteDocuments(Term...) and deleteDocuments(Query...) keep the caller's array without copying it. Documents still buffered in RAM are deleted by whatever the array holds when the per-thread writer applies the delete, so refilling it afterwards deletes the wrong documents, silently and durably; clearing it makes a later call throw. Example on main, one thread: flushed1 and flushed2 in segment _0; buffered1, buffered2 and keep in RAM. Step 1, deleteDocuments(batch) with batch = flushed1, buffered1; the queue node keeps batch itself. Step 2, the caller refills batch with flushed2, buffered2 and calls deleteDocuments(batch) again; node 1 now means the new terms. Step 3, commit() flushes and both nodes read batch now: buffered2 twice, buffered1 never. Step 4, after commit, expected live [keep], actual [buffered1, keep]. Flushed segments get the call-time contents through the global slice. Table: Term[] refilled for the next batch, expected [keep], actual [buffered1, keep], with the fix [keep]; Query[] slot reassigned to id:keep, expected [keep], actual [delete-me], with the fix [keep]; Query[] cleared with Arrays.fill, expected commit ok and [keep], actual commit() NullPointerException and the writer closed, with the fix commit ok and [keep]. Literal varargs are safe. Affected: 4.0.0-ALPHA to 10.5.2 and main; 3.6.2 copies in the call.
The two-call example on one reused Term[2]: flushed documents get the call-time terms, buffered ones the terms the array holds at commit; plus the reassigned Query[] slot and a cleared Query[], each with the result on main and with the copy. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 7 documents, d0 to d5 and keep, with the default IndexWriterConfig. They wait in the writer's RAM buffer until the next flush.
  2. Delete d0 to d5 in 3 batches with one reused array: fill a Term[2] with {d0, d1} and call deleteDocuments(batch), refill it with {d2, d3} and call again, then {d4, d5}. Then commit().
  3. You expect only keep to be left. That is what you get with a new array per call, or when the documents were flushed before the deletes.
  4. Instead the commit keeps [d0, d1, d2, d3, keep]. The writer stored the array itself, once per call, and read it only at commit, when it held {d4, d5}. No error.
  5. A slot change alone is enough: after deleteDocuments(queries) with queries = {id:delete-me}, setting queries[0] = id:keep keeps delete-me and deletes keep.

Who is affected

Applications that pass their own Term[] or Query[] and change it before the writer's next document or flush, such as a batch loop with one reused buffer or clearing it after use. Default configuration, any index size; silent and committed. Literal varargs like deleteDocuments(t1, t2) are safe.

Mechanism, terms and numbers652 words

IndexWriter.deleteDocuments(Term...) and deleteDocuments(Query...) hand the caller's array itself to the writer's delete queue. During the call the queue copies the elements into the buffer that serves segments already flushed, so those documents are deleted by what the array held at the call. Each per-thread writer applies the delete to the documents it still buffers in RAM only later, at its next addDocument or at flush, and reads the array as it is then.

So a caller that refills or reuses its array after the call deletes buffered documents by the new contents. With the default IndexWriterConfig, a loop that refills one Term[2] with {d0, d1}, {d2, d3}, {d4, d5} commits [d0, d1, d2, d3, keep] instead of [keep]; the same loop with a new array per call, or with the documents flushed first, commits [keep]. Reassigning queries[0] after the call deletes a document no call named. Clearing a Query[] after the call makes the next commit a tragic writer failure that loses all uncommitted documents. Every release from 4.0.0-ALPHA (2012) through 10.5.2 and main is affected; a 2-line copy in IndexWriter fixes it.

Terms

  • RAM buffer, per-thread writer: IndexWriter collects new documents in memory, in one or more per-thread writers, until a flush writes them as a new segment. A flush happens at commit(), when an NRT reader is opened or refreshed, when the RAM buffer is full (16 MB by default) or on flush().
  • Delete queue: deletes are appended to one shared queue. A global slice of it serves the segments that are already flushed; each per-thread writer has its own delete slice for the documents it buffers.
  • Node: one queue entry per deleteDocuments call, so all of a call's deletes become visible together.

What happens (main a9779ef3c193)

  1. deleteDocuments(Term... terms) calls docWriter.deleteTerms(terms) (IndexWriter.java:1867); the Query... overload calls docWriter.deleteQueries(queries) (:1894). DocumentsWriter passes the array on (DocumentsWriter.java:141-147), and DocumentsWriterDeleteQueue.addDelete appends new TermArrayNode(terms) / new QueryArrayNode(queries) (DocumentsWriterDeleteQueue.java:123-133). The node stores the array itself, not a copy (:462-465, :475-478).
  2. Flushed segments, call-time contents. addDelete then calls tryApplyGlobalSlice() (:125, :131). When its tryLock succeeds (always with one thread) it applies the node to the global buffer during the call (:198-215), copying each element (:467-472, :480-485). That buffer is applied to segments already flushed.
  3. Buffered documents, later contents. A per-thread writer applies its slice only when it finishes its next document (DocumentsWriterPerThread.java:361-389, apply at :377 and :383) or when it flushes (prepareFlush, :427-436, apply at :434). The node's apply loops over the stored array as it is at that moment (for (Term term : item), DocumentsWriterDeleteQueue.java:482).
  4. The batch loop. Three calls with one array add three nodes that point at the same array. At commit the per-thread writer applies all three; each reads {d4, d5}. So the {d4, d5} terms are added three times (BufferedUpdates.addTerm keeps one entry per term, so d4 and d5 are deleted once) and d0 to d3 are never deleted.
  5. Clearing the array. A null slot reaches BufferedUpdates.addTerm at the next addDocument (which throws a NullPointerException, and the delete is dropped), or, for queries, IndexSearcher.rewrite while the commit applies the segment's deletes; an exception there is a tragic event (IndexWriter.java:2933-2942) that closes the writer.

For one indexing thread the window closes at that writer's next indexed document (any add or update call, such as addDocument or updateDocument) or at the next flush (such as commit(), an NRT reopen or flush()); another delete call does not close it. With several threads each per-thread writer applies the node at its own next document, and if tryApplyGlobalSlice misses its tryLock, flushed segments read the array late too; both come from code reading only, every run was single-threaded.

Exposure and likelihood263 words
  • Who is hit. Code that passes an explicit Term[] or Query[] to deleteDocuments and then changes it before that per-thread writer indexes another document or flushes: refilling one buffer per batch, clearing it with Arrays.fill(buf, null), or reusing it for something else. Literal varargs (deleteDocuments(t), deleteDocuments(t1, t2)) get a fresh array from the compiler, and code that builds a new array per call is safe.
  • Default configuration, mainstream API. The repro uses the default IndexWriterConfig, codec and merge policy and one thread; nothing is opted into.
  • Scale. Every document added since the last flush can be affected; the repros use 2 to 7 documents because size does not matter.
  • Silent and durable. The call returns its sequence number, there is no exception, and the commit persists both errors: documents the caller deleted stay searchable (also through NRT readers), and documents nobody named are gone. No structural corruption was observed (readers open the committed index normally; CheckIndex was not run).
  • Clearing is worse. After deleteDocuments(Query[]), Arrays.fill(buf, null) makes the next commit() throw a NullPointerException; the writer closes on the tragic event and every uncommitted document is lost (committed live set []). After deleteDocuments(Term[]), the next unrelated addDocument throws a NullPointerException and the delete is dropped (the writer stays open).
  • How likely. Rare. Code search found no Lucene module and no Elasticsearch, OpenSearch or Solr main-code call site that reuses an array (they pass one argument or build a new array per call), and nobody has reported it since 4.0. Code search can miss call sites.

Minimal repro

The test shows that IndexWriter#deleteDocuments(Query...) keeps the caller's array, so reassigning its slot after the call returned changes which buffered document the delete removes at the next commit.

TestDeleteDocumentsReusedQueryArray.java (JUnit test)60 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 java.util.Set;
import java.util.TreeSet;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.Bits;

public class TestDeleteDocumentsReusedQueryArray extends LuceneTestCase {

  public void testReusedQueryArray() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
      for (String id : new String[] {"delete-me", "keep"}) {
        Document doc = new Document();
        doc.add(new StringField("id", id, Field.Store.YES));
        writer.addDocument(doc);
      }
      Query[] queries = {new TermQuery(new Term("id", "delete-me"))};
      writer.deleteDocuments(queries);
      queries[0] = new TermQuery(new Term("id", "keep")); // the call has returned; reuse the array
      writer.commit();

      Set<String> live = new TreeSet<>();
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        LeafReader leaf = getOnlyLeafReader(reader);
        Bits liveDocs = leaf.getLiveDocs();
        for (int i = 0; i < leaf.maxDoc(); i++) {
          if (liveDocs == null || liveDocs.get(i)) {
            live.add(leaf.storedFields().document(i).get("id"));
          }
        }
      }
      // expected [keep] (the call deleted "delete-me"); actual [delete-me]: "keep" is deleted instead
      assertEquals(Set.of("keep"), live);
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-644/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestDeleteDocumentsReusedQueryArray
Expected vs actual
Expected only keep to survive the commit; actual expected:<[keep]> but was:<[delete-me]> (delete-me survives, keep is deleted), with -ea and -da.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[keep]> but was:<[delete-me]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.index.TestDeleteDocumentsReusedQueryArray.testReusedQueryArray(TestDeleteDocumentsReusedQueryArray.java:57)

Why it looks unintended

  • The javadoc speaks of "all given deletes" and never says the array is kept; the other APIs checked that keep caller data say so (MultiPhraseQuery.Builder.add(Term[])).
  • One call gives two answers: flushed documents are deleted by the call-time terms, buffered ones by later contents.
  • Lucene 3.x copied the elements during the call; no test or discussion asks for retention.
The full argument338 words
  • The contract speaks of the deletes given. "Deletes the document(s) containing any of the terms. All given deletes are applied and flushed atomically at the same time." and "@param terms array of terms to identify the documents to be deleted" (IndexWriter.java:1856-1860; the same for queries at :1875-1879). Nothing says the array is kept or must not be changed.
  • Lucene documents retention in the other places checked where it keeps caller data. MultiPhraseQuery.Builder.add(Term[]): "The array is not copied or mutated, the caller should consider it immutable subsequent to calling this method" (MultiPhraseQuery.java:111-112, :122-123). Field/StoredField: "the provided byte[] is not copied so be sure not to change it until you're done with this field" (Field.java:156, :172; StoredField.java:68). The Term objects passed in are already protected: "The provided BytesRef is copied when it is non null." (Term.java:50).
  • The result depends on flush timing. In one call, flushed documents see the call-time contents and buffered documents the later contents. The class javadoc presents flushing as buffering ("These changes are buffered in memory and periodically flushed", IndexWriter.java:123) and says a change's sequence number "expresses the effective order in which each change was applied" (:117-118); here what the change deletes is decided after its number is assigned.
  • History. 3.x applied the elements inside the call (Term[] deletes since 2.1.0, Query[] since 2.4.0). Retention came with the new (then lock-free) delete queue of LUCENE-2956 (#4030, 2011), whose review asked that a Term[]/Query[] delete be atomic, hence one node per call; nobody discussed copying or reusing the array.
  • Tests. No upstream test reuses an array at the public API. The only identity assertion is the package-private queue test TestDocumentsWriterDeleteQueue.testUpdateDeleteSlices ("create an array here since we compare identity below against tailItem", TestDocumentsWriterDeleteQueue.java:51-56); it passes with the boundary copy.
  • Precedent. LUCENE-6821 (#7879, 2015) made Term copy its BytesRef because the aliasing had "bitten a few people"; #12158 (2023) cloned a caller's BytesRef[] in KeywordField#newSetQuery.
The devil's-advocate check144 words

The strongest case for "works as designed" (the intended-behaviour checker and an intended-lens review by OpenAI GPT-5.6): IndexWriter buffers its changes, Java does not require defensive copies, and the delete queue keeps its payload on purpose (an internal test asserts identity), so a caller must not modify an array after passing it in.

Why it fails: the public methods say nothing, while Lucene documents retention in the other places checked where it keeps caller data (MultiPhraseQuery.Builder, Field, StoredField); within one call the outcome depends on whether a document was flushed; the identity test covers the internal queue only; upstream removed the same kind of trap by copying; and a routine cleanup (Arrays.fill(buf, null)) turns into a tragic writer failure. The intended-behaviour checker remains only moderately sure about the remedy: upstream may prefer a javadoc note to a copy.

Suggested fix

Recommended Copy the array in IndexWriter.deleteDocuments (validated)

Copy the array where it enters IndexWriter: docWriter.deleteTerms(terms.clone()) and docWriter.deleteQueries(queries.clone()) (2 lines). The test then passes 3 of 3 with assertions on and off, and the core index test package passes with it (215 suites, 1,398 tests, 0 failed). Cost: one shallow array copy per call.

   public long deleteDocuments(Term... terms) throws IOException {
     ensureOpen();
     try {
-      return maybeProcessEvents(docWriter.deleteTerms(terms));
+      // copy: the deletes are resolved against buffered documents after this call returns
+      return maybeProcessEvents(docWriter.deleteTerms(terms.clone()));
@@ deleteDocuments(Query... queries)
-      return maybeProcessEvents(docWriter.deleteQueries(queries));
+      // copy: the deletes are resolved against buffered documents after this call returns
+      return maybeProcessEvents(docWriter.deleteQueries(queries.clone()));
All options and trade-offs4 options ยท 522 words

One fix was built and validated; the alternatives come from the finder, the reviewers and the checkers.

Copy the array in IndexWriter.deleteDocuments (validated) (recommended)

Clone the varargs array before it is handed to the delete queue, in both overloads (IndexWriter.java:1867, :1894). The Term and Query objects are not copied; Term already copies its bytes.

   public long deleteDocuments(Term... terms) throws IOException {
     ensureOpen();
     try {
-      return maybeProcessEvents(docWriter.deleteTerms(terms));
+      // copy: the deletes are resolved against buffered documents after this call returns
+      return maybeProcessEvents(docWriter.deleteTerms(terms.clone()));
@@ deleteDocuments(Query... queries)
-      return maybeProcessEvents(docWriter.deleteQueries(queries));
+      // copy: the deletes are resolved against buffered documents after this call returns
+      return maybeProcessEvents(docWriter.deleteQueries(queries.clone()));

Trade-offs

One shallow copy of the varargs array per call (one reference per term or query), not per document. It also removes the cleared-array failures. In the Query... overload the copy is taken after the MatchAllDocsQuery check, so another thread could still change the array between the two; a variant, not run, clones once at the top and uses the copy for both the check and the delete. The copy covers the array slots only: a caller that later mutates a custom Query object it passed in is still exposed.

What the checks validated

  • In a scratch worktree of a9779ef3c193, through gradle: stock fails the test 3 of 3 with assertions on and off; with the copy it passes 3 of 3 with assertions on and off.
  • 20 closest suites pass with assertions on and off (347 tests, 18 skipped, 0 failed each): TestIndexWriterDelete, TestDocumentsWriterDeleteQueue, TestIndexWriter, TestIndexWriterReader, TestIndexWriterExceptions, TestIndexWriterExceptions2, TestStressDeletes, TestStressNRT, TestRollingUpdates, TestTryDelete, TestIndexingSequenceNumbers, the doc-values update suites and others. The whole core index package: 215 suites, 1,398 tests, 0 failed.
  • With the copy shadowed, the plain program, the finder's and the independent reproducer's tests and the security probe all pass.
  • checkGoogleJavaFormat passes with the fix and the test in place.
  • Not run: the full test suite and any benchmark.

Copy inside DocumentsWriterDeleteQueue.addDelete (validated by the checks)

Store terms.clone() / queries.clone() in the queue node (DocumentsWriterDeleteQueue.java:123-133).

Trade-offs

Also removes the bug (shown at the introducing commit), but it fails the internal identity assertion of TestDocumentsWriterDeleteQueue.testUpdateDeleteSlices (1 failure; stock passes), so that test would change too. The only production entry path is IndexWriter, through DocumentsWriter (package tests call addDelete directly), so it gains nothing over the boundary copy today.

What the checks validated

Executed: the queue-level copy fails testUpdateDeleteSlices and passes TestIndexWriterDelete; at the introducing commit it turns the failing scenario into a pass.

Document that the array is kept

Add "The array is not copied; do not modify it after this call" to both javadocs, in the style of MultiPhraseQuery.Builder.add(Term[]).

Trade-offs

Zero cost, but the trap stays: the flush-timing-dependent result, the lost and unrequested deletes, and the tragic commit after a cleared Query[]. Existing callers that reuse arrays stay broken.

Apply the elements to every per-thread writer during the call (not recommended)

What Lucene 3.x did.

Trade-offs

Gives up the shared-queue, one-node-per-call design of the delete queue for no gain over the boundary copy.

Downstream note

Only the application's own code can trigger this, by changing its array after the call; an application that routes delete requests through one shared buffer can leave a document it deleted (for example for erasure) searchable and delete another user's buffered document instead.

How it was found

An AI tool listed every IndexWriter method that takes a caller-owned mutable argument and classified each argument, from the code, as copied, consumed in the call, or kept. A probe then changed each argument after the call returned and compared a buffered document with a flushed one; for deleteDocuments they disagreed.

The method in full134 words

Method: an argument-ownership table checked by mutate-after-return probes (the study's "semi-formal" tier). A hunt unit (IndexWriter update-value ownership), leg 2.

  • From code reading, the AI tool built a table of every IndexWriter entry point that takes a caller-owned mutable object (arrays, BytesRefs, fields) and classified each argument as copied, consumed inside the call, or kept.
  • Each row was witnessed by an executed probe on the real main jars: change the argument after the call returned, then compare a document buffered in RAM with one in a flushed segment.
  • For deleteDocuments(Term...) the same call deleted the flushed document and not the buffered one. The AI tool minimised that to the 2-test JUnit repro, and the fix and the checks followed.
  • No model checker, solver or prover was involved; the table guided where to probe.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27, 187 read-only queries over Lucene issues, PRs, JIRA, commits and mailing lists, Elasticsearch, OpenSearch, Solr, Lucene.NET and the web). The closest item, a 2014 remark that IndexWriter does not clone the Term object, is a different defect, later fixed in Term.

Searches8 searches

Searched for the array retention under its class names (TermArrayNode, QueryArrayNode: 0 issues, PRs or JIRA entries), and for the symptoms (reused or mutated delete arrays, wrong documents deleted, varargs copies). Every hit was unrelated or a user error.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs, open and closed (migrated JIRA issues included)2026-10-27a scripted prior-report search (two specs) and own GitHub searches: 85 queries, plus class-name searches across all repositoriesno report; TermArrayNode and QueryArrayNode match nothing
commit history2026-10-27commit-message search, git log -S/--grep on the full history, recent commits per filethe node classes are added only in the introducing commit (LUCENE-2956; a 2021 branch cleanup that deletes the tree also matches); no commit adds a copy
JIRA archive (LUCENE, SOLR)2026-10-2717 queries; 14 issues read with all commentsno report; LUCENE-2956's review discussed atomicity, not copying
dev@, java-user@, issues@, commits@2026-10-2740 queries; two threads read in fullno report; the class names appear only in commit mails
Elasticsearch, OpenSearch, Solr, Lucene.NET2026-10-2710 queriesno report
open apache/lucene PRs2026-10-27changed-file scan of all 409 open PRs; 6 diffs read24 touch the delete path; none changes how the array is passed on (#16578 rewrites the MatchAllDocsQuery check)
related item (read)2026-10-27#6253 (LUCENE-5189), comments of 2014-03-15IndexWriter does not clone the Term object passed to updateNumericDocValue or deleteDocuments(Term): a different defect, no failure reported; fixed by #7879 (LUCENE-6821), Term copies its bytes
web2026-10-2712 queries plus Stack Exchange readshow-to questions and user errors only

History

Introduced by LUCENE-2956 (e95097a7c8d, 2011), whose new delete queue holds the caller's array; first released in 4.0.0-ALPHA (2012). Every release from 4.0.0-ALPHA through 10.5.2 and main fails; 3.6.2 copied the elements. Both introducing commits were bisected by execution.

Versions tested7 rows

Introduced by e95097a7c8d (LUCENE-2956 "Support updateDocument() with DWPTs", #4030, by @s1monw, 2011-04-13, on the realtime_search branch). It added DocumentsWriterDeleteQueue, whose TermArrayNode/QueryArrayNode hold the caller's array (one node per call, so a call's deletes become visible atomically, as the review asked), and replaced the loop that copied the elements into every per-thread writer during the call. It reached trunk with the merge 2a969f828c1 (LUCENE-3023 "Land DWPT on trunk", #4097, 2011-05-01). Later edits (generics, the BufferedUpdates rename, sequence numbers in 2016) kept passing the array through.

Releases before 9.12.3 ran a main() port of the same scenario adapted to their API. By reading, all 123 release tags in the history clone from 4.0.0-alpha to 10.5.2 build the node from the array with no copy; the clone has no tags for 8.8.2 to 8.11.3, which lie between executed failing releases.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis page's JUnit 3/3 fail (gradle and runner, -ea/-da); the finder's 2/2 fail; plain program fails
10.5.2, 9.12.3 affectedthis page's JUnit 3/3 fail; plain program fails; the finder's 2/2 fail (-ea and -da, history step)
8.11.4 affectedplain program fails; the finder's and the history step's main() ports fail
4.0.0-ALPHA, 4.0.0-BETA, 4.0.0, 4.10.4, 5.0.0, 5.5.5, 6.6.6, 7.0.0, 7.7.3 affectedmain() ports fail (expected 0 0 1 0 1, got 1 0 1 1 0)
3.1.0, 3.6.2 not affectedcorrect with -ea and -da: the elements are copied during the call
e95097a7c8d (LUCENE-2956) vs its parent a7d460ca144 bisectthe commit fails, its parent (per-thread writers already present) is correct; the commit with a clone in addDelete is correct (-da only)
2a969f828c1 (LUCENE-3023 merge) vs trunk parent 0528549d178 bisectthe merge fails, its trunk parent is correct

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, OpenAI GPT-5.6 reviews that each ran their own repro, an adjudication and a credibility audit all say real, each with a test it wrote or re-ran that fails on main.

Checks and verdicts339 words

All checks ran on main a9779ef3c193 unless noted, with fixed seeds recorded in their result files. Every run is single-threaded.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateJUnit with a fixed config: 2/2 fail with -ea and -da, 40/40 with --iters 20, also on 10.5.2 and 9.12.3; main() ports fail on 8.11.4 to 5.0.0; passes with the IndexWriter copy.
Independent reproducer realOwn public-API JUnit written from the claim: 4 claim tests fail and 3 controls pass on main (-ea, -da), 10.5.2 and 9.12.3; 100/100 claim iterations fail with random seeds; 7/7 pass with the copy.
Refute review (OpenAI GPT-5.6, executed) real (high)Own repro: 2 of 4 fail with -ea and -da, the clone controls pass; proposed the IndexWriter-boundary copy. The answer came from a second model of that family; it ran the previous main ea57360912ed (these files unchanged).
Intended-lens review (OpenAI GPT-5.6, executed) real (high)Own contract test: 2 of 6 fail on main, 10.5.2 and 9.12.3, flushed-document controls pass, 6/6 with the copy; rated the intended argument weak. Re-run on a9779ef3c193.
Intended-behaviour devil's advocate realRejected every works-as-designed argument; its default-config batch loop fails with -ea and -da ([d0, d1, d2, d3, keep]); moderate on whether upstream prefers a copy or a javadoc note.
Adjudication confirmed (high)Re-ran the independent reproduction's test with a fresh seed (7/4, -ea and -da) and the finder's (-da, 2/2); data corruption, no duplicate.
Security screen not security-sensitiveTwo-party probe (lost erasure, rejected request applied, cleared arrays) on main and 10.5.2; out of scope under SECURITY.md; all fixed by the copy.
Credibility audit keepRe-ran the finder's test with seed 5A1D0C47: 2/2 fail with -ea and -da, 2/0 with the copy; code re-read.
This page reproduced, fix validatedNew 3-test JUnit and plain program: fail on main, 10.5.2, 9.12.3 (and 8.11.4, plain); gradle in a scratch worktree, stock 3/3 fail and with the copy 3/3 pass, related suites 0 failures.

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

Finding record

LFV-644, snapshot of 2026-11-06

Registry title
IndexWriter.deleteDocuments(Term...) and deleteDocuments(Query...) keep the caller's array without copying it, so documents still buffered in RAM are deleted by whatever the array holds when the per-thread writer later applies the delete (at its next addDocument or flush): a caller that refills or clears its array after the call returned keeps documents it deleted and deletes documents no call named, silently and also after commit (since 4.0.0)
Area
index / IndexWriter deletes (deleteDocuments, DocumentsWriterDeleteQueue array nodes, per-thread delete slices)
Priority
Low, rank 260: Core API and durable, but only callers that change a Term[] or Query[] after passing it are hit (no known caller), which maintainers may treat as misuse.
Impact class
data corruption
Impact note
Hits applications that pass an explicit Term[] or Query[] to IndexWriter.deleteDocuments and then change that array's slots (refill it for the next batch, clear it, reuse it) before the per-thread writer holding the targets indexes its next document or flushes; with the default IndexWriterConfig, codec and merge policy, one thread, any size, deterministically and the same with -ea and -da, the committed index keeps documents the call deleted and loses still-buffered documents nobody asked to delete (the intended-behaviour check: a default-config batch loop that refilled one Term[2] three times lost two batches and applied the third three times), and clearing a Query[] after the call makes the next commit a tragic writer failure that loses all uncommitted documents [exec]; calls with literal varargs (deleteDocuments(t1, t2)) get a fresh array from the compiler and are safe, documents in already-flushed segments see the call-time contents (single-threaded runs), and code search found no Lucene module and no Solr, Elasticsearch or OpenSearch main-code caller that reuses the array.
Summary
You pass a Term[] or Query[] to IndexWriter.deleteDocuments, the call returns its sequence number, and you then reuse the array, for example refill it with the next batch of ids; the delete should mean the terms or queries the array held at the call, as it already does for documents in flushed segments. IndexWriter hands the array itself to its delete queue, and each per-thread writer reads it only later, at its next addDocument or flush, so documents still buffered in RAM are deleted by the array's new contents. In the repro, deleting {flushed1, buffered1} and then refilling the same array with {flushed2, buffered2} and deleting again leaves buffered1 alive after commit, and reassigning the slot of a one-element Query[] after the call keeps 'delete-me' and deletes 'keep', which no delete call named; no exception is thrown.
Introduced
e95097a7c8dfc042c09288717822768c6011f5e8 (LUCENE-2956 'Support updateDocument() with DWPTs', apache/lucene#4030, patch by s1monw, realtime_search branch; JIRA/SVN patch era, no pull request): added DocumentsWriterDeleteQueue, whose TermArrayNode/QueryArrayNode store the caller's Term[]/Query[] (one node per call so the call's deletes become visible atomically, as the reviewer mikemccand asked), and replaced DocumentsWriter's loop that copied the elements into every per-thread writer during the call. It reached trunk, the line every release since 4.0 comes from, in merge 2a969f828c1b65d81f0e630188dd8244694765b9 (LUCENE-3023 'Land DWPT on trunk', apache/lucene#4097). Later edits (e555950a3e6 generics, ec4720f4c29 BufferedUpdates rename, 058970e72bd6 sequence numbers 2016) kept passing the array through [read: git blame -w, git log -S TermArrayNode]. [exec: commit-level builds, -ea and -da: a7d460ca144 (parent, per-thread writers already present) correct / e95097a7c8d wrong; 0528549d178 (trunk first parent) correct / 2a969f828c1 wrong; e95097a7c8d with a clone in addDelete correct] (2011-04-13 (e95097a7c8d, realtime_search branch); 2011-05-01 (2a969f828c1, trunk))
First affected version
4.0.0-ALPHA [exec: fails with -da in a plain javac/java run because the harness rejects the -ALPHA suffix; 4.0.0 GA fails with -ea and -da]; 3.6.2 and 3.1.0, the last releases before, copy the elements during the call and pass with -ea and -da [exec]
Affected versions
every release from 4.0.0-ALPHA through 10.5.2 (the last release before 10.6.0) and main a9779ef3c193, never fixed. [exec] the follow-up history step fails on 4.0.0-ALPHA, 4.0.0-BETA, 4.0.0 (-ea/-da), 4.10.4, 5.0.0, 7.7.3, 8.11.4 (-da), 9.12.3, 10.5.2 and main (-ea/-da); the finder's records add 5.5.5, 6.6.6 and 7.0.0; the independent reproduction re-ran 10.5.2 (-ea/-da) and 9.12.3 (-ea), and the security probe 10.5.2 (-da). Releases before 9.12.3 ran a main() port of the same scenario adapted to their API; all runs on JDK 25, single-threaded. [read] git tag --contains 2a969f828c1 lists all 123 release tags 4.0.0-alpha..10.5.2 in the history clone (the 8.8.2-8.11.x tags are not in that clone; 8.11.4 was executed), and every tag builds new TermArrayNode(terms)/new QueryArrayNode(queries) with no clone, copyOf or arraycopy on the path. Not affected: 3.6.2 and earlier (executed 3.1.0 and 3.6.2; read 2.1.0-3.0.0, where Term[] deletes exist since 2.1.0, Query[] since 2.4.0, varargs since 3.0.0).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record verdict new: 187 queries, 144 through a scripted prior-report search and 43 own, over apache/lucene issues and PRs including migrated JIRA issues, commit messages plus git log -S on the full history, Elasticsearch, OpenSearch, Solr and Lucene.NET, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the changed-file lists of all 409 open apache/lucene PRs, and the web; TermArrayNode and QueryArrayNode match 0 issues or PRs and 0 JIRA entries). Related items, not this bug: #6253 (LUCENE-5189) comments of 2014-03-15 noting that IndexWriter does not clone the Term passed to updateNumericDocValue or deleteDocuments(Term) (the Term object, not the array, and no failure reported; that trap was removed by #7879 / LUCENE-6821, which made Term copy its bytes); precedents for a defensive copy: #7879 and #12158 (clone of a caller's BytesRef[] in KeywordField#newSetQuery); open PRs #13486, #16578 and #13398 touch the delete path but not how deleteDocuments passes its array on.
Lucene commit tested
Lucene main a9779ef3c193 (tested 2026-10-27) through gradle in a scratch worktree and with the standalone JUnit runner, with assertions on and off; this page's JUnit test also on the 10.5.2 and 9.12.3 release jars, its plain program also on 8.11.4 (SHA-1 checked against Maven Central); the finder's test on 10.5.2 and 9.12.3 and main() ports from 8.11.4 back to 4.0.0-ALPHA; commit-level builds of both introducing commits and their parents. JDK 25.0.3, Linux aarch64 only, one indexing thread.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1855-1898 (at a9779ef3c193) (javadoc 'All given deletes are applied and flushed atomically at the same time', '@param terms array of terms to identify the documents to be deleted'; docWriter.deleteTerms(terms) at 1867 and deleteQueries(queries) at 1894 pass the caller's array; MatchAllDocsQuery check on the call-time contents at 1886-1891), :117-121 (sequence numbers express 'the effective order in which each change was applied')
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:141-147 (at a9779ef3c193) (deleteQueries/deleteTerms pass the array to addDelete)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:123-133 (at a9779ef3c193) (addDelete wraps the array in QueryArrayNode/TermArrayNode, no copy), :198-215 (tryApplyGlobalSlice copies the elements into the global buffer during the call when its tryLock succeeds), :462-491 (the nodes iterate the retained array whenever a slice is applied)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:361-389 (at a9779ef3c193) (finishDocuments applies the slice at the next document), :427-436 (prepareFlush applies it at flush)
  • lucene/core/src/test/org/apache/lucene/index/TestDocumentsWriterDeleteQueue.java:50-56 (at a9779ef3c193) ('create an array here since we compare identity below against tailItem': internal identity assertion)
  • lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:111, (at a9779ef3c193) :122 ('The array is not copied or mutated': Lucene's explicit note where it does retain an array)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1855-1898 (at a9779ef3c193) (both overloads pass the caller's array; MatchAllDocsQuery check at 1886-1891), :2933-2942 (applyUpdatesPacket tragic event)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:141-147 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterDeleteQueue.java:123-133 (at a9779ef3c193) (addDelete, no copy), :198-215 (tryApplyGlobalSlice), :462-491 (QueryArrayNode/TermArrayNode iterate the stored array)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:361-389 (at a9779ef3c193) (finishDocuments), :427-436 (prepareFlush)
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/WritableQueryIndex.java:118, (at a9779ef3c193) :340; lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java:151; lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/FilteredKnnVectorQueryBenchmark.java:158 (single-argument callers)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:111-123 (at a9779ef3c193) (sequence numbers; buffering), :1855-1882 (javadoc)
  • lucene/core/src/java/org/apache/lucene/search/MultiPhraseQuery.java:111, (at a9779ef3c193) :122; lucene/core/src/java/org/apache/lucene/document/Field.java:156, :172; StoredField.java:68; lucene/core/src/java/org/apache/lucene/index/Term.java:50
  • lucene/core/src/test/org/apache/lucene/index/TestDocumentsWriterDeleteQueue.java:51-56 (at a9779ef3c193)

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

Repro files

About the diagrams

A summary card (PNG) of the finder's two-call scenario, made from executed probe runs on main (-ea, -da, and with the copy). The card uses the flushed-versus-buffered example, the steps above use the batch loop; both are in the JUnit test.

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.