All findings · Lucene finding · LFV-904 · delete-query-rewritten-per-segment

deleteDocuments(Query) rewrites the query on each segment alone, so it can delete a different set than search returns

IndexWriter.deleteDocuments(Query) resolves the query on each segment separately, so queries whose matches depend on other documents delete a layout-dependent set: on two segments, a kNN query that finds 2 documents deletes 4, and a default FuzzyQuery that finds 50 deletes 60. Silent, and permanent once committed.

Summary

Priority
Medium, rank 12This is a new, deterministic, permanent wrong-delete bug in core deleteDocuments/updateDocuments(Query) on default config, but only reader-dependent delete queries trigger it.
Impact
data corruption
Since
2.4.0 (2008), with deleteDocuments(Query) itselfintroduced 2008-03-06 · over 18 years
Affected
all releases 2.4.0-10.5.2 by reading (22 run), 10.6.0 (branch_10_6 at RC1, run) and main
Status
documented
Found by
differential test: search vs delete

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

Same query, same documents: only the segment layout changes what is deleted.

Summary card titled "Delete-by-query rewrites the query on each segment alone, so it deletes the wrong set". Panel 1, the executed kNN example: 8 vectors, q = (2, 3), docs 0-3 in segment 0 and docs 4-7 in segment 1; search over the whole index with k = 2 returns docs 0 and 1; the delete takes the top 2 of each segment and removes docs 0, 1, 4 and 6; with the same documents in one segment it removes 0 and 1. Panel 2, other queries on two segments, search vs delete counts: TermQuery control 2 and 2, kNN 2 and 4, default FuzzyQuery 50 and 60, top-terms PrefixQuery 2 and 4, CommonTermsQuery 2 and 3, MoreLikeThisQuery 6 and 0, score range 4 and 0. A bottom strip: per-document predicates are not affected by this, and with the same documents committed in one segment each delete equals the search; deleting the search hits by a unique key is an exact workaround; upstream options.
The diagram's example: 8 vectors in two segments. Search for the 2 nearest to q finds docs 0 and 1; the delete removes 0, 1, 4 and 6, the 2 nearest of each segment. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index six documents with vectors (1, 1), (2, 1), ... (6, 1) in two commits, so ids 0-2 and ids 3-5 are two segments (a plain IndexWriterConfig; the test pins the real default codec so the test framework does not randomize it, and turns merges off only to keep the layout). search(new KnnFloatVectorQuery("vector", {0, 1}, 2)) returns ids 0 and 1.
  2. Call deleteDocuments with the same query and commit. You expect ids 0 and 1 to be deleted: it "Deletes the document(s) matching" the query, and k is "the number of documents to find".
  3. You get ids 0, 1, 3 and 4 deleted, the 2 nearest of each segment. No error. With the same six documents in one segment, exactly ids 0 and 1 are deleted. updateDocuments(query, docs) does the same.
  4. Why: when the delete is applied, applyQueryDeletes builds new IndexSearcher(segmentReader) for each segment and rewrites the query there, so kNN's top k, FuzzyQuery's top 50 terms and index statistics are computed per segment, and the deleted set is the union.
  5. Other queries, two segments: a default FuzzyQuery name:lucenex~2 finds 50 and deletes 60; MoreLikeThisQuery and a score range find 6 and 4 and delete none. Term, range and other per-document queries are not affected.

Who is affected

Applications that delete or update by a query whose matches depend on other documents: kNN ("delete the k nearest"), a typed fuzzy term (Lucene's own Luke "Delete Docs" passes the search query to deleteDocuments), top-N term rewrites, CommonTermsQuery, MoreLikeThisQuery, score ranges. Default config, indexes with two or more segments. Per-document queries are not affected by this.

Mechanism, terms and numbers752 words

IndexWriter.deleteDocuments(Query...) and updateDocuments(Query, docs) do not run the query over the whole index the way a search does. When the buffered delete is applied, FrozenBufferedUpdates.applyQueryDeletes builds a new IndexSearcher over each segment, rewrites the query there and deletes that segment's matches. For per-document predicates (a term, a range) that changes nothing. For queries whose matches depend on the other documents of the index (kNN's top k, the default FuzzyQuery's top 50 terms and other top-N rewrites, CommonTermsQuery, MoreLikeThisQuery, score ranges) each segment picks its own matches, so the delete removes more or fewer documents than IndexSearcher.search returns for the same query: on two segments a kNN query with k = 2 deletes 4 documents and a default FuzzyQuery deletes 60 where search finds 50, while MoreLikeThisQuery and a score range delete none of the 6 and 4 hits. With the same documents committed in one segment every delete equals the search, so the segment layout alone decides what is deleted, silently and permanently once committed. It is as old as the API (2.4.0, 2008): executed on 22 releases from 2.4.0 to 10.5.2, on 10.6.0 (branch_10_6 at RC1 4ae946e5a9b) and on main, all affected, and every release tag since 2.4.0 has the same per-segment code. updateDocuments(Query, ColumnBatch) (since 10.5.0) uses the same delete path (by reading).

Terms

  • Segment: an index is a set of independent segments; each commit or flush can add one, and merges combine them. Users do not choose the layout; the default merge policy changes it over time.
  • Rewrite: before a query runs, IndexSearcher.rewrite turns it into a primitive query using the searcher's reader. kNN does its real work here (it finds the top k), and a FuzzyQuery picks its top 50 terms here.
  • Delete packet: deleteDocuments(Query) only buffers the query. It is applied later (at flush, commit, NRT reopen or merge) by FrozenBufferedUpdates.applyQueryDeletes.

The bug

  1. applyQueryDeletes loops over the segments the packet applies to and, for each one, runs new IndexSearcher(readerContext.reader()), searcher.rewrite(query) and createWeight (FrozenBufferedUpdates.java:397-400 on main).
  2. So every reader-dependent decision is made per segment: kNN's top k over that segment's vectors (AbstractKnnVectorQuery.rewrite, :75-118), the top 50 fuzzy terms among that segment's terms (FuzzyQuery.java:34-36, 56), CommonTermsQuery's high/low frequency split against that segment's maxDoc (CommonTermsQuery.java:113, 154-155), MoreLikeThis's minDocFreq and idf against that segment (MoreLikeThis.java:634-660), and the BM25 statistics behind a score range.
  3. The deleted set is the union of the per-segment results. An independent check that replaces "search on the index" with "union of one-segment searches" as the expected result passes all 9 of its tests.
  4. One call can even be resolved several times: once for the segments that already exist, and once more for each segment flushed from documents buffered before the call (a segment-private packet always covers exactly one segment). So for ordinary query deletes the writer never builds one searcher over all segments (an exact MatchAllDocsQuery is not resolved at all: it becomes deleteAll()). One segment is not always safe either: in the 2016-cell matrix, when the documents are still buffered at the call and more are added after it, the one flushed segment also holds the later documents, and the default fuzzy query deletes 49 of its 50 search hits, top-terms(5) 2 of 5, kNN k = 2 none of 2 and a score range 31 of 36.

The numbers (two segments, default codec)

querysearch returnsdelete removes
KnnFloatVectorQuery k = 2ids 0, 1ids 0, 1, 3, 4
default FuzzyQuery name:lucenex~250 documents60 documents
PrefixQuery, TopTermsScoringBooleanQueryRewrite(2)ids 0, 1ids 0, 1, 3, 4
CommonTermsQuery (common, rare)ids 0, 5ids 0, 4, 5
MoreLikeThisQuery like:zeta6 documentsnone
score-range FunctionRangeQuery4 documentsnone
TermQuery (control)ids 0, 3ids 0, 3

With the same documents committed in one segment, every delete equals the search. On three segments the first probe saw kNN k = 2 delete 6 documents and the default fuzzy query 90 where search finds 50. In a bounded-exhaustive check (every contiguous layout of 8 documents, every set partition of 6), search returned one fixed set per query on every layout, while the kNN, top-2 fuzzy, top-terms and score-range deletes mismatched on 127 of 128 and 202 of 203 layouts; the per-document controls (a term, a point range, a default prefix, a fuzzy query whose 1024-term cap is never reached, and a boolean of a term and a range) matched in all 1655 checks.

Exposure and likelihood407 words
  • Who is hit. Applications that delete or update by a query whose matches depend on other documents: kNN ("delete the k nearest", or replace them with updateDocuments(Query, docs)), a parsed or typed fuzzy term in a delete-by-query feature, top-N MultiTermQuery rewrites, CommonTermsQuery, MoreLikeThisQuery, and score ranges (FunctionRangeQuery, FunctionMatchQuery).
  • Lucene's own Luke does this. Its "Delete Docs" button asks "Are you sure to permanently delete the documents?", then passes the current search query to IndexWriter.deleteDocuments and commits. By reading (Luke itself was not run), a user who searched name:lucenex~2 on such an index and saw 50 hits would lose 60 documents.
  • Default configuration. Default IndexWriterConfig, codec and merge policy; indexes with two or more segments, the normal case, when a segment's own top k or top terms differ from the index's. The JUnit pins the layout with NoMergePolicy; the independent reproducer also fails with the default TieredMergePolicy, and the 2016-cell matrix shows the same through an NRT reader, through updateDocuments(Query, docs) and with the last segment still buffered.
  • Silent and permanent. No error, with assertions on or off. Over-deletion is data loss once committed; under-deletion leaves documents that a retention or erasure delete was meant to remove.
  • Not affected by this. Per-document predicates. Executed controls: a term, a point range, a default prefix, a fuzzy query whose 1024-term cap is never reached and a boolean of a term and a range matched search in all 1655 layout checks; a term, a point range, a prefix, a wildcard, the same fuzzy query and a vector-similarity threshold matched in all 576 control cells of the matrix. Term-set and field-value function queries were not executed; they are per-document by reading. This covers this mechanism only: stale doc values in delete-by-query (#8398) is a separate, known bug.
  • How likely. Rare: a ranking or statistics query is an unusual delete predicate, no upstream test uses one, and the searches below found no prior report.
  • Workaround. Search a near-real-time reader and delete the hits by a unique id term (or try tryDeleteDocument per hit, an expert best-effort call that returns -1 when it cannot delete); deleting by id removes exactly the search hits. Pre-rewriting a fuzzy query on a whole-index searcher also works, but not a kNN query: the rewritten kNN query throws "This DocAndScore query was created by a different reader" when the delete is applied and closes the writer (a separate, known bug).

Minimal repro

The test shows that IndexWriter.deleteDocuments(Query) resolves a kNN query on each segment separately, so on two segments it deletes a different set than IndexSearcher returns for the same query.

TestDeleteByKnnQuery.java (JUnit test)56 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.KnnFloatVectorField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.KnnFloatVectorQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestDeleteByKnnQuery extends LuceneTestCase {

  public void testDeleteMatchesSearchOnTwoSegments() throws Exception {
    IndexWriterConfig iwc =
        new IndexWriterConfig()
            .setCodec(TestUtil.getDefaultCodec())
            .setMergePolicy(NoMergePolicy.INSTANCE);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (float x : new float[] {0, 10}) {
        Document doc = new Document();
        doc.add(new KnnFloatVectorField("v", new float[] {x, 0}));
        w.addDocument(doc);
        w.commit();
      }
      Query query = new KnnFloatVectorQuery("v", new float[] {0, 0}, 1);
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        assertEquals(1, new IndexSearcher(reader).count(query));
      }
      w.deleteDocuments(query);
      w.commit();
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        // expected: only the 1 search hit is deleted (1 doc left); actual: the top 1 of each segment
        assertEquals("live docs after deleteDocuments(k=1)", 1, reader.numDocs());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-904/, 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.TestDeleteByKnnQuery
Expected vs actual
Expected: KnnFloatVectorQuery k=1 matches 1 of the 2 documents (one per segment), so 1 document stays live; actual: the top 1 of each segment is deleted, 0 left (expected:<1> but was:<0>), with -ea and -da.

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

More files, environment, full output37 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: live docs after deleteDocuments(k=1) expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.TestDeleteByKnnQuery.testDeleteMatchesSearchOnTwoSegments(TestDeleteByKnnQuery.java:52)

Why it looks unintended

  • The javadoc promises to delete "the document(s) matching any of the provided queries"; nothing documents per-segment resolution, and no upstream test deletes with such a query.
  • kNN works in rewrite so that "the number of documents it matches doesn't depend on the number of segments" (#11838).
  • Lucene's own Luke deletes search hits by passing the search query to deleteDocuments.
The full argument300 words
  • Contract. deleteDocuments(Query...): "Deletes the document(s) matching any of the provided queries" and "@param queries array of queries to identify the documents to be deleted" (IndexWriter.java:1875, 1879); updateDocuments(Query, docs): "take a query instead of a term to identify the documents to be updated" (:1613-1614); updateDocuments(Query, ColumnBatch) (since 10.5.0): "takes a query instead of a term to identify the documents to be deleted" (:1560-1561). No restriction on the query kind; the only special case is MatchAllDocsQuery, turned into deleteAll().
  • The query classes define matches over the whole reader. KnnFloatVectorQuery: "@param k the number of documents to find"; FuzzyQuery: "Only the top terms are used". On #11838 (2022) a Lucene developer explained that kNN does its work in rewrite to "match the global top k across segments, as opposed to matching k docs per segment. This means the number of documents it matches doesn't depend on the number of segments".
  • Nothing documents per-segment resolution. No javadoc, package doc, MIGRATE.md or CHANGES.txt entry says delete queries are resolved per segment or must be per-document predicates; the only "per segment" wording is the class comment of the package-private BufferedUpdatesStream, about resolving deletes to docIDs.
  • Untested, not decided. Upstream tests delete by query only with per-document predicates.
  • Lucene's own usage. Luke's "Delete Docs" passes the current search query to deleteDocuments: it assumes a delete matches the search.
  • History. Per-segment resolution is as old as the API (LUCENE-1194, 2008); its discussion and the later rewrites of the resolution code (LUCENE-6688, LUCENE-7868, LUCENE-8242) never discussed reader-dependent queries, which mostly arrived later (fuzzy with 50 terms in 4.0.0, CommonTermsQuery in 4.1.0, kNN in 9.0.0); the two that existed in 2008, FuzzyQuery's top-1024-terms rewrite and contrib MoreLikeThisQuery, are not mentioned in the LUCENE-1194 discussion.
The devil's-advocate check263 words

The intended-behaviour checker's strongest case (rated moderate by every checker): per-segment, deferred resolution is the original, deliberate design, and one delete is resolved in several packets, so the writer never has a whole-index view. On #8398 (stale doc values in delete-by-query) a committer wrote that the resolution code "has a hard assumption that segments are write-once as far as queries resolving to docIDs is concerned" and leaned "towards requiring/documenting" a limitation; another suggested "Maybe we should just document the limitation". Query#rewrite depends on the searcher and matching is per leaf, so each deleted document does match the query as rewritten on its own segment; kNN, MLT, CommonTerms and score ranges are ranking tools, not predicates; kNN search is approximate anyway, and the writer exposes no snapshot of the index at the time of the call; an exact way to delete what a search returned is a delete by a unique id.

Why it fails: nothing tells users, the queries are accepted silently, and the result is permanent. It fails in the cleanest case (everything committed, then one delete), where search returns one fixed set on every layout and only the layout changes what is deleted. #8398 is still open as a bug (the same committer opened with "This is indeed a bug"). Luke relies on delete matching search, and for kNN there is no general way to keep the query as the delete query and get the search set: pre-rewriting it on the whole index throws when the delete is applied. Verdict: a real, undocumented, silent defect; the fix may legitimately be documentation.

Suggested fix

Recommended Document per-segment resolution in the javadoc (recommended)

Document it: say in the deleteDocuments(Query...) and updateDocuments(Query, ...) javadoc that each query is resolved on each segment alone, so queries whose matches depend on other documents (kNN, the default FuzzyQuery, score ranges) can delete a different set than search; delete search hits by id. A code alternative (rewrite once per packet) passes the tests but is partial.

 * <p><b>NOTE</b>: each query (other than a {@link MatchAllDocsQuery}, which deletes all
 * documents) is resolved against each segment on its own, with an {@link
 * org.apache.lucene.search.IndexSearcher} over that one segment, not against the whole index.
 * Queries whose matches depend on the other documents of the index or on index statistics, such
 * as {@link org.apache.lucene.search.KnnFloatVectorQuery} (the top k), {@link
 * org.apache.lucene.search.FuzzyQuery} with its default top-terms rewrite, or a range over
 * scores, can therefore delete more or fewer documents than {@link
 * org.apache.lucene.search.IndexSearcher#search(Query, int)} returns for the same query (with
 * {@code n} large enough to collect every hit), depending on how the index is split into
 * segments. To delete exactly the hits of a search, search a near-real-time reader and delete the
 * hits by an id term or with {@link #tryDeleteDocument(IndexReader, int)}.
All options and trade-offs4 options ยท 977 words

Two patches were written and run in a scratch worktree at a9779ef3c193. Neither is presented as the obvious fix: the checkers expect upstream to answer with documentation first.

Document per-segment resolution in the javadoc (recommended) (validated by the checks)

javadoc-per-segment-note.diff, +18 lines in IndexWriter.java, javadoc only: a NOTE on deleteDocuments(Query...) (which excepts MatchAllDocsQuery, turned into deleteAll()) and a pointer from both updateDocuments(Query, ...) overloads (Iterable and ColumnBatch). Revised after the fact checks; the first version lacked the MatchAllDocsQuery exception, the 'collect every hit' condition and the ColumnBatch pointer.

 * <p><b>NOTE</b>: each query (other than a {@link MatchAllDocsQuery}, which deletes all
 * documents) is resolved against each segment on its own, with an {@link
 * org.apache.lucene.search.IndexSearcher} over that one segment, not against the whole index.
 * Queries whose matches depend on the other documents of the index or on index statistics, such
 * as {@link org.apache.lucene.search.KnnFloatVectorQuery} (the top k), {@link
 * org.apache.lucene.search.FuzzyQuery} with its default top-terms rewrite, or a range over
 * scores, can therefore delete more or fewer documents than {@link
 * org.apache.lucene.search.IndexSearcher#search(Query, int)} returns for the same query (with
 * {@code n} large enough to collect every hit), depending on how the index is split into
 * segments. To delete exactly the hits of a search, search a near-real-time reader and delete the
 * hits by an id term or with {@link #tryDeleteDocument(IndexReader, int)}.

Trade-offs

Smallest change; it describes what every release since 2.4.0 does; and it is in line with the tentative suggestions on #8398 ("would lean towards requiring/documenting", "Maybe we should just document the limitation"; about stale doc values, and still open). It leaves the silent wrong delete for anyone who does not read the note, and Luke's "Delete Docs" would need its own change (delete the hits by docID through an NRT reader). The note could also say that the internal searcher uses default settings (for example the default Similarity), which a related check found undocumented.

What the checks validated

Revised note, on a clean extraction of a9779ef3c193: ./gradlew :lucene:core:tidy formatted it; ./gradlew :lucene:core:check -x test with both patches and this doc's test: BUILD SUCCESSFUL (format check, ecj lint, forbidden APIs); ./gradlew :lucene:core:renderJavadoc: BUILD SUCCESSFUL, and the rendered IndexWriter.html holds the three notes with all six links resolved (the extracted text and link targets are saved). The first version passed the same checks. It changes no behaviour, so the repro still fails, by design.

Rewrite each query once over all segments of the delete packet (validated, partial)

rewrite-once-per-packet.diff, +49/-33 in FrozenBufferedUpdates.java. applyQueryDeletes first collects the segments the packet applies to (the same two skip checks), wraps their readers in one new MultiReader(readers, false), rewrites and weights each query once on an IndexSearcher over it, and then runs the scorer on each leaf with the unchanged docID-limit and index-sort logic.

try (MultiReader reader = new MultiReader(readers, false)) {
  final IndexSearcher searcher = new IndexSearcher(reader);
  searcher.setQueryCache(null);
  for (int i = 0; i < deleteQueries.length; i++) {
    final Query query = searcher.rewrite(deleteQueries[i]);
    final Weight weight = searcher.createWeight(query, ScoreMode.COMPLETE_NO_SCORES, 1);
    for (int s = 0; s < readers.length; s++) {
      final Scorer scorer = weight.scorer(reader.leaves().get(s));
      // ... unchanged: delete the matches below the packet's docID limit

Trade-offs

  • Partial. It fixes the clean cases on segments that exist when the delete is applied: everything committed, then a delete (the repro's case), and the NRT and updateDocuments paths. It does not fix documents buffered before the call (still resolved in their own one-segment private packet: in the matrix the "last segment still buffered" path keeps all 28 of its mismatching BM25 cells), documents added after the call (20 of 30 remain), kNN after an earlier delete in the same writer (8 of 30 remain, a separate known bug about stale live docs), or the non-default-Similarity cells. A full fix needs one resolution point over all segments, including the buffered ones: a design change.
  • Inside a private packet, top k is still chosen among all documents of the flushed segment, including those added after the call (then cut by the docID limit): unchanged from today.
  • Cost: one MultiReader and one rewrite per packet instead of one rewrite per segment; no benchmark was run. Concurrency with merges was not reviewed beyond the test runs.
  • A pre-rewritten kNN query still throws when applied and closes the writer (a separate, known bug).

What the checks validated

  • Gradle, scratch worktree with the patch, seed DEADBEEF: this doc's test plus the whole core org.apache.lucene.index test package, TestKnn* and TestFuzzyQuery: 219 classes, 1536 tests, 0 failures (56 skipped), with assertions on and off; the 3 tests that fail on stock pass.
  • The repro runner, stock main with the patched class shadowed: the finder's 7 tests pass (-ea, -da) and the independent reproducer's 9 tests pass; without the shadow the finder's class still fails 6 of 7.
  • The 2016-cell matrix (BM25, 3 segments): the committed, NRT and updateDocuments paths drop from 28 of 30 mismatching cells to 0; the "last segment still buffered" path stays at 28 of 30; the "documents added after the call" path goes from 28 to 20. Per-document controls: 0 mismatches before and after. Identical with assertions off.
  • Not run: the full Lucene test suite, nightly tests, benchmarks.

Reject reader-dependent queries

Throw from deleteDocuments for queries whose matches depend on other documents. Not run.

Trade-offs

There is no general way to tell this from a Query; a list of known classes (kNN, top-N rewrites, CommonTerms, MLT, score sources) would be fragile and would break code that accepts today's behaviour.

Resolve each delete query against a whole-index view at a single point

Resolve the query once against all segments, including the buffered documents, at the delete's point in time. Not run.

Trade-offs

Correct by construction, but it needs an NRT-like view of the buffered documents (or a flush) per delete: a much larger change to the write path.

Downstream note

Downstream products that offer delete-by-query with kNN, fuzzy or other ranking queries, or a "delete what I searched" action, inherit the layout-dependent delete set; deleting the search hits by id avoids it.

How it was found

A differential test ran the same query through IndexSearcher.search and IndexWriter.deleteDocuments and compared the sets. The idea and first probe came from an ideation round by a model from another family (OpenAI GPT-5.6); a finder then ran a 2016-cell matrix and an exhaustive check of every small segment layout.

The method in full213 words

Method: Differential Query Execution, the database-testing idea of running one predicate as a SELECT and as a DELETE, aimed at Lucene's write path. Non-formal by the hunt's rules: the first signal was an executed probe.

  • Lead. In a cross-model ideation round, an independent model from another family (OpenAI GPT-5.6, testing-research framing) proposed comparing deleteDocuments(Q) with search(Q). Its probe on the stock main jar printed MISMATCH on 3 segments for kNN k = 2 (6 deleted vs 2 searched), the default FuzzyQuery (90 vs 50), TopTerms(5) (15 vs 5) and CommonTermsQuery (13 vs 40), and passed on 1 segment and for the TermQuery control (also on 10.5.2 and 9.12.3). A planning verifier re-ran every number.
  • Hunt. A finder ran a Differential Query Execution matrix: 21 query families (6 per-document controls) x 6 write paths (committed, NRT, updateDocuments, last segment buffered, after an earlier delete, followed by later documents) x 1/3 segments x 2 merge policies x 4 Similarities = 2016 cells, then a bounded-exhaustive layout check (every contiguous layout of 8 documents and every set partition of 6, 4303 checks), and wrote the public-API JUnit.
  • Checks. An independent reproducer, an executed review by a model from another family, an intended-behaviour devil's advocate, an adjudication and a credibility audit (section "Independent checks").

Prior reports, history and checks

Prior reports

No report found (2026-10-28: three automated search runs over GitHub, JIRA, the mailing lists and downstream code, 35 manual GitHub searches, every PR since 2026-10-20). Closest: #8398 (stale doc values in delete-by-query), #11838 (kNN intent) and elastic/elasticsearch#10067 (a replica re-run "possibly matching different documents", no mechanism named).

Searches4 searches

Verdict new. elastic/elasticsearch#10067 is flagged: if read as a partial overlap, the hunt's novelty rule would make this bug "known"; the follow-up, the adjudication and the audit read it as a different issue (concurrent indexing on a replica).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, JIRA, mailing lists, downstream repositories (automated)2026-10-28scripted prior-report searches 2 and 3 (51 and 36 queries: issues, PRs, commits, dev@, java-user@, issues@, commits@, LUCENE and SOLR JIRA) plus the finder's run 11655, 1276 and 1253 distinct hits; no report of this bug
GitHub (manual)2026-10-2835 searches over apache/lucene, apache/solr, Elasticsearch and OpenSearch; the file lists of every PR created 2026-10-20 to 2026-10-28 (#16872-#16926)no report; no open PR changes the per-segment delete searcher (#13398 and #13684 touch applyQueryDeletes and keep it; #13486 changes FrozenBufferedUpdates' term deletes only)
Related threads (read)2026-10-28#8398, #11838, #4680, #2271, #7746, elastic/elasticsearch#10067 and #5828, SOLR-17677, SOLR-6357context only: stale doc values (#8398), kNN's segment-independence intent (#11838), the exception form of whole-index queries in the delete searcher (SOLR-17677, SOLR-6357, elastic#5828), replica re-execution (elastic#10067)
Web and SOLR JIRA2026-10-2810 web queries, 9 SOLR JIRA queriesunrelated results

History

As old as the API: per-segment resolution came with deleteDocuments(Query) in LUCENE-1194 (5b113c8af63c, 2008), first released in 2.4.0, and every later rewrite kept it. Executed on 22 releases from 2.4.0 to 10.5.2, 10.6.0 (branch_10_6 at RC1) and main: all affected. kNN joined the triggers in 9.0.0.

Versions tested8 rows

Introduced by 5b113c8af63c "LUCENE-1194: add IndexWriter.deleteDocuments(Query)" (#2271, svn r634219, 2008-03-06, no PR). The first implementation already ran new IndexSearcher(reader) and query.weight(searcher) for each SegmentReader. Later rewrites of the resolution code (LUCENE-2680 and LUCENE-1076 in 3.1/3.2, QueryWrapperFilter per segment in 3.3-5.2.1, LUCENE-6688 (5d16b973962, backported as 914ba735b56) in 5.3.0, LUCENE-7868 58105a203a1 in 7.0.0, LUCENE-8242 798d3510341 in 8.0.0) all kept the per-segment searcher, with identical executed numbers on both sides of each. Which stock queries trigger it grew over time: MoreLikeThisQuery and the default FuzzyQuery (1024 terms) from 2.4.0, TopTerms rewrites from 3.1, the default FuzzyQuery with 50 terms from 4.0.0, CommonTermsQuery from 4.1.0, score ranges from 5.4.0, kNN from 9.0.0, updateDocuments(Query, docs) from 9.7.0 and updateDocuments(Query, ColumnBatch) from 10.5.0 (not executed). Not fixed upstream: FrozenBufferedUpdates.java last changed on 2026-09-21 (#16581), not in this code.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 3 of 5 fail (-ea, -da); finder 6 of 7, independent reproducer 7 of 9; history probe
branch_10_6 4ae946e5a9b affectedfinder's test 6 of 7; history probe
9.0.0, 9.5.0, 9.12.3, 10.0.0, 10.5.2 affectedkNN k = 2 deletes 4 vs 2, fuzzy 60 vs 50, CommonTerms 3 vs 2, MLT 0 vs 6 (-da too on 9.12.3 and 10.5.2)
4.1.0, 4.10.4, 5.0.0, 5.2.1, 5.3.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4 affectedfuzzy 60 vs 50, CommonTerms 3 vs 2, MLT 0 vs 6
4.0.0 affectedfuzzy 60 vs 50, MLT 0 vs 6
2.4.0, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2 affecteddefault fuzzy deletes 1074 where search returns 1024; MLT 0 vs 6
2.3.2 API absentno deleteDocuments(Query): the probe does not compile
main + rewrite-once patch partial fixall repro tests pass; the buffered-segment path still mismatches

Independent checks

The finder, an independent reproducer, an executed review by a model from another family, a devil's advocate, an adjudication and a credibility audit all reproduced it on main; the adjudication confirmed it with medium confidence, because upstream may answer "document it". This doc's gradle runs and fix checks are below.

Checks and verdicts409 words

Every check re-ran a repro; the reviews that could not run code are listed as procedural.

Each independent check and its verdict
CheckVerdictWhat it found
lead check (planning verifier) numbers re-runRe-ran every number of the ideation probe on 3 segments; the 1-segment and TermQuery controls pass.
finder candidateJUnit 6 of 7 fail with -ea and -da, also on branch_10_6, 10.5.2 and 9.12.3; 2016-cell matrix; exhaustive layout check (per-document controls match in all 1655 checks).
cross-model reviews proceduralOpenAI GPT-5.6, reading-only because it could not start commands; intended argument rated moderate, inputs in contract; separate runs re-ran the repro: 6 of 7 fail.
independent reproducer (the independent reproduction, Claude) realOwn JUnit 7 of 9 fail on main (-ea, -da), 10.5.2 and 9.12.3, also with the default merge policy and through updateDocuments; the per-segment-union oracle passes 9 of 9; a rewrite-once shadow class passes 9 of 9.
cross-model adversary real, mediumOpenAI wrote and ran its own test: kNN k = 1 on 2 segments deletes [10, 20] where search returns [10]; re-run on main, 10.5.2 and 9.12.3. It could see the other verifiers' files. It ran Java, and a verifier re-ran its test on a9779ef3c193.
intended-behaviour devil's advocate (the intended-behaviour check, Claude) real, mediumThe by-design case is moderate and undocumented; a parsed name:lucenex~2 deletes 60 vs 50 (main, 10.5.2); rewritten once on the whole index it deletes exactly 50.
adjudication confirmed, mediumRe-ran the independent reproduction's JUnit with new seeds 1DEC0DE5 (-ea) and 1DEC0DE6 (-da): 7 of 9 fail.
Registration check and credibility audit keepFinder's JUnit 6 of 7 fail (seeds 5EED0352, A4D17004 -ea, A4D17005 -da); the independent reproduction's 7 of 9 (-da, 5EED0353).
this doc's runs reproduced; fixes checkedGradle: the 5-test JUnit fails 3 of 5 on stock (-ea, -da); the rewrite-once patch and the javadoc patch as in Suggested fix; the matrix with the patch.
fact checks of this doc (Claude; OpenAI GPT-5.6; diagram check) bug holds; doc correctedThe Claude fact check re-ran this doc's gradle command (3 of 5 fail, -ea and -da), the rewrite-once patch and the finder's class; the cross-model check read every claim against the saved evidence; the diagram check re-ran the diagram probe. Their wording errors (control coverage, a PR description, a backport hash, the history of 2008-era queries, the javadoc note's MatchAllDocsQuery exception, a one-segment overclaim on the summary card) are fixed.

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

Finding record

LFV-904, snapshot of 2026-11-06

Registry title
IndexWriter.deleteDocuments(Query) and updateDocuments(Query, ...) rewrite the query separately on each segment, so reader-dependent queries (kNN, the default FuzzyQuery and other top-N term rewrites, CommonTermsQuery, MoreLikeThisQuery, score ranges) delete a set that depends on the segment layout and differs from what IndexSearcher.search returns
Area
core / index write path (delete-by-query: FrozenBufferedUpdates.applyQueryDeletes) x core / search and queries (reader-dependent rewrites)
Priority
Medium, rank 12: This is a new, deterministic, permanent wrong-delete bug in core deleteDocuments/updateDocuments(Query) on default config, but only reader-dependent delete queries trigger it.
Impact class
data corruption
Impact note
Medium by judgement: applications that delete or update by a query whose matches depend on other documents (kNN top-k, the default FuzzyQuery and other top-N term rewrites, CommonTermsQuery, MoreLikeThisQuery, a score-range FunctionRangeQuery) get a deleted set that depends on the segment layout, silently and permanently once committed, with the default IndexWriterConfig, codec and merge policy on any index with 2 or more segments, on every release since 2.4.0 (kNN since 9.0.0) and on main; it over-deletes (kNN k=2 removes 4 documents on 2 segments, a default FuzzyQuery removes 60 where search finds 50) or under-deletes (MoreLikeThisQuery and a score range remove none of the 6 and 4 that search finds), per-document predicates (term, range, set, field-value function queries) are unaffected, and using a reader-dependent query as a delete query is an unusual pattern.
Summary
You call IndexWriter.deleteDocuments(query) (or updateDocuments(query, docs)) on an index with two or more segments and expect it to delete the documents IndexSearcher.search(query) returns, since the javadoc says it 'Deletes the document(s) matching any of the provided queries'. Instead the writer rewrites the query on a separate one-segment IndexSearcher for each segment, so top-k, top-N term expansion and index statistics are computed per segment: a kNN query with k=2 deletes up to 2 documents per segment (4 on two segments), a default FuzzyQuery deletes 60 documents where search finds 50, and MoreLikeThisQuery or a score-range predicate deletes none of the documents search returns. With the same documents committed in one segment before the delete, the delete equals the search result, so the segment layout alone decides what is deleted, and the result is permanent once committed.
Introduced
5b113c8af63c (LUCENE-1194 / #2271, 'add IndexWriter.deleteDocuments(Query)', svn r634219, Subversion era, no PR) [read: git log -S 'public void deleteDocuments(Query query)' over all historical IndexWriter.java paths in the full-history clone, oldest hit; git merge-base --is-ancestor: in releases/lucene/2.4.0, not in 2.3.0-2.3.2]. The first implementation already resolved a delete query per segment: DocumentsWriter.applyDeletes did 'IndexSearcher searcher = new IndexSearcher(reader); Weight weight = query.weight(searcher)' for each SegmentReader (DocumentsWriter.java:2794-2801 at the commit), and Query.weight starts with searcher.rewrite(this). Every later rewrite of the resolution code kept the per-segment searcher: LUCENE-2680 / LUCENE-1076 (BufferedDeletes, BufferedDeletesStream, 3.1/3.2), QueryWrapperFilter per segment (3.3-5.2.1), 5d16b973962 (LUCENE-6688 / #7746, 2015, new IndexSearcher(readerContext.reader())), 58105a203a1 (LUCENE-7868 / #8919, 2017, FrozenBufferedUpdates) and 798d3510341 (LUCENE-8242 / #9289, 2018, explicit searcher.rewrite + createWeight). On main the lines are FrozenBufferedUpdates.java:397-400 (git blame -w -M -C: 397-398 from 5d16b973962, 399-400 from 798d3510341). Not a regression: the bug is as old as the API; what changed over time is only which stock queries expose it. (2008-03-06 (5b113c8af63, author date = commit date))
First affected version
2.4.0 [exec: the history probe fails with -ea and -da], the first release with IndexWriter.deleteDocuments(Query) (CHANGES 'Release 2.4.0 2008-10-06', New features item 9); 2.3.2 and older have no such method [exec: compile error, both javac errors are the deleteDocuments(Query) calls]. Trigger families by release [read: per-tag scan]: MoreLikeThisQuery and the default FuzzyQuery (then capped at 1024 terms) from 2.4.0, explicit TopTerms rewrites from 3.1, the default FuzzyQuery with maxExpansions = 50 from 4.0.0, CommonTermsQuery from 4.1.0, score-range FunctionRangeQuery from 5.4.0, kNN from 9.0.0 (KnnVectorQuery) / 9.5.0 (KnnFloatVectorQuery), and the second entry point updateDocuments(Query, Iterable) from 9.7.0 (#12346).
Affected versions
every release from 2.4.0 to 10.5.2, 10.6.0 (branch_10_6 head 4ae946e5a9b, the RC1 revision) and main a9779ef3c193. [exec] portable history probes (public API; reference = search on a 2-segment index, plus a per-segment-union and a one-segment control) fail on 22 releases (2.4.0, 2.4.1, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.1.0, 4.10.4, 5.0.0, 5.2.1, 5.3.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.5.0, 9.12.3, 10.0.0, 10.5.2; -da also on 2.4.0, 9.12.3 and 10.5.2), branch_10_6 and main (-ea and -da): default FuzzyQuery 1074 deleted vs 1024 searched (2.x-3.x) or 60 vs 50 (4.0.0 on), MoreLikeThisQuery 0 vs 6, CommonTermsQuery 3 vs 2 (4.1.0 on), kNN k=2 4 vs 2 (9.0.0 on); in every deviating check the deleted set equals the per-segment union, the one-segment layout deletes exactly the search set, and the numbers do not change across any rewrite of the resolution code. The finder's JUnit fails 6 of 7 on main (-ea, -da), branch_10_6, 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 7 of 9 on main (-ea, -da), 10.5.2 and 9.12.3; re-run at the registration check on main (-ea and -da). [read] every one of the 136 release tags from 2.4.0 to 10.5.2 in the clone resolves a delete query with a one-segment IndexSearcher (or a QueryWrapperFilter that builds one). Not executed: updateDocuments(Query, ...) on releases (exercised on main by the finder's matrix and the independent reproduction's JUnit), the explicit TopTerms and score-range triggers before 9.12.3. Not fixed upstream: FrozenBufferedUpdates.java last changed in 169cbffd12f (#16581, author date 2026-09-21), which does not touch the delete-query searcher [read, commits API, 2026-10-28]. All runs on JDK 25 (aarch64 Linux).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that stayed undecided, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only; refreshed at the registration check 2026-10-28: none of the 15 PRs and 7 issues created on apache/lucene since 2026-10-26 (#16907-#16928) is about delete queries, and the commits API shows FrozenBufferedUpdates.java and IndexWriter.java last changed by 169cbffd12f (#16581, author date 2026-09-21), which does not touch the delete-query searcher): scripted prior-report searches 2 and 3 (51 + 36 queries over GitHub issues, PRs and commits, downstream repositories, dev@/java-user@/issues@/commits@, LUCENE and SOLR JIRA; 1655 and 1276 distinct hits) plus the finder's run 1 (1253 hits), 35 manual GitHub searches over apache/lucene, apache/solr, Elasticsearch and OpenSearch, the file lists of every PR created 2026-10-20..28 (#16872-#16926), 9 SOLR JIRA and 10 web queries. Context, not reports: open #8398 / LUCENE-7344 (LFV-016, stale doc values in delete-by-query; a maintainer described a 'hard assumption that segments are write-once as far as queries resolving to docIDs is concerned' and leaned to documenting limitations), #11838 (search-side intent: kNN does its work in rewrite so its matches do not depend on the number of segments; no mention of deletes), #4680 / LUCENE-3606 (remark that a delete query is handed each segment reader), elastic/elasticsearch#10067 and #7052 (2015: a delete-by-query re-executed on a replica 'possibly matching different documents', no mechanism named, concurrent-indexing context), SOLR-17677, SOLR-6357 and elastic/elasticsearch#5828 (queries needing a whole-index view throw in the per-segment delete searcher: the LFV-823 family), a code comment in Solr's DeleteByQueryWrapper ('IW's applyQueryDeletes isn't much better'), #2271 and #7746 (introduced and reshaped per-segment resolution; semantics for reader-dependent queries never discussed); open PRs #13398, #13486 and #13684 touch applyQueryDeletes without changing the per-segment searcher
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree: this doc's 5-test JUnit on stock with assertions on and off; the rewrite-once patch with the whole core org.apache.lucene.index test package plus the kNN and fuzzy query tests, assertions on and off; both patches with the core module's static checks; the repro runner: the finder's 7-test class, the independent reproducer's 9-test class, the cross-model reviewer's own test, the devil's-advocate probe, the adjudication's, registry and audit re-runs, several seeds, assertions on and off; the 2016-cell differential matrix on stock and with the patch); branch_10_6 head 4ae946e5a9b; portable history probes on 22 releases from 2.4.0 to 10.5.2; 2.3.2 (API absent). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:363 (at a9779ef3c193) (applyQueryDeletes), :397-400 (new IndexSearcher(readerContext.reader()), setQueryCache(null), searcher.rewrite(query), createWeight per segment)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1874-1898 (at a9779ef3c193) (deleteDocuments(Query...) javadoc 'Deletes the document(s) matching any of the provided queries' and body), :1612-1623 (updateDocuments(Query, docs), @lucene.experimental, 'take a query instead of a term to identify the documents to be updated')
  • lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:75 (at a9779ef3c193) (rewrite: top k over the searcher's leaves); lucene/core/src/java/org/apache/lucene/search/FuzzyQuery.java:35 ('Only the top terms are used'), :56 (defaultMaxExpansions = 50); lucene/queries/src/java/org/apache/lucene/queries/CommonTermsQuery.java:113,154-155 (maxDoc and docFreq of the rewrite reader); lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThisQuery.java and MoreLikeThis.java (numDocs, minDocFreq of the rewrite reader)
  • lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:666 (at a9779ef3c193) and lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java:145-151 (Delete Docs passes the current search query to IndexWriter.deleteDocuments)
  • SECURITY.md lines 28, 38, 42, 50 (threat model, untrusted query input, trusted configuration, in-scope reports)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/FrozenBufferedUpdates.java:363, (at a9779ef3c193) 387-401 (applyQueryDeletes, the per-segment searcher), :166-169 (a private packet has one segment)
  • lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:84-87 (at a9779ef3c193) (segment-private packet)
  • lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:75-118; (at a9779ef3c193) FuzzyQuery.java:34-36, 56
  • lucene/queries/src/java/org/apache/lucene/queries/CommonTermsQuery.java:105-117, (at a9779ef3c193) 154-155; mlt/MoreLikeThis.java:634-660
Who is affected
  • lucene/luke/src/java/org/apache/lucene/luke/app/desktop/components/SearchPanelProvider.java:651-672; (at a9779ef3c193) lucene/luke/src/java/org/apache/lucene/luke/models/tools/IndexToolsImpl.java:145-153; lucene/luke/src/resources/org/apache/lucene/luke/app/desktop/messages/messages.properties:229
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1559-1573, (at a9779ef3c193) 1612-1623, 1874-1899
  • lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:60; (at a9779ef3c193) FuzzyQuery.java:34-36
  • lucene/core/src/java/org/apache/lucene/index/BufferedUpdatesStream.java:37-43 (at a9779ef3c193)
  • github.com/apache/lucene/issues/11838 (comment by jtibshirani, read 2026-10-28)

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

Repro files

About the diagrams

A step-by-step picture of an executed probe on main (default IndexWriterConfig, assertions on and off), made in parallel with this doc. The PNG is the final summary frame.

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.