Same query, same documents: only the segment layout changes what is deleted.
- 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. - Call
deleteDocumentswith the same query and commit. You expect ids 0 and 1 to be deleted: it "Deletes the document(s) matching" the query, andkis "the number of documents to find". - 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. - Why: when the delete is applied,
applyQueryDeletesbuildsnew 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. - Other queries, two segments: a default
FuzzyQueryname:lucenex~2finds 50 and deletes 60;MoreLikeThisQueryand a score range find 6 and 4 and delete none. Term, range and other per-document queries are not 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).
- 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.rewriteturns it into a primitive query using the searcher's reader. kNN does its real work here (it finds the top k), and aFuzzyQuerypicks its top 50 terms here. - Delete packet:
deleteDocuments(Query)only buffers the query. It is applied later (at flush, commit, NRT reopen or merge) byFrozenBufferedUpdates.applyQueryDeletes.
applyQueryDeletesloops over the segments the packet applies to and, for each one, runsnew IndexSearcher(readerContext.reader()),searcher.rewrite(query)andcreateWeight(FrozenBufferedUpdates.java:397-400on main).- 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'smaxDoc(CommonTermsQuery.java:113, 154-155),MoreLikeThis'sminDocFreqand idf against that segment (MoreLikeThis.java:634-660), and the BM25 statistics behind a score range. - 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.
- 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
MatchAllDocsQueryis not resolved at all: it becomesdeleteAll()). 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.
| query | search returns | delete removes |
|---|---|---|
KnnFloatVectorQuery k = 2 | ids 0, 1 | ids 0, 1, 3, 4 |
default FuzzyQuery name:lucenex~2 | 50 documents | 60 documents |
PrefixQuery, TopTermsScoringBooleanQueryRewrite(2) | ids 0, 1 | ids 0, 1, 3, 4 |
CommonTermsQuery (common, rare) | ids 0, 5 | ids 0, 4, 5 |
MoreLikeThisQuery like:zeta | 6 documents | none |
score-range FunctionRangeQuery | 4 documents | none |
TermQuery (control) | ids 0, 3 | ids 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-NMultiTermQueryrewrites,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.deleteDocumentsand commits. By reading (Luke itself was not run), a user who searchedname:lucenex~2on 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 withNoMergePolicy; the independent reproducer also fails with the defaultTieredMergePolicy, and the 2016-cell matrix shows the same through an NRT reader, throughupdateDocuments(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
tryDeleteDocumentper 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).
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.
/*
* 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());
}
}
}
}
# 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:
KnnFloatVectorQueryk=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-eaand-da.
More files, environment, full output37 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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 isMatchAllDocsQuery, turned intodeleteAll(). - 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 topkacross segments, as opposed to matchingkdocs 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.mdorCHANGES.txtentry 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-privateBufferedUpdatesStream, 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,
CommonTermsQueryin 4.1.0, kNN in 9.0.0); the two that existed in 2008,FuzzyQuery's top-1024-terms rewrite and contribMoreLikeThisQuery, 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.
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.
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-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 limitTrade-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
updateDocumentspaths. 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
MultiReaderand 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.indextest package,TestKnn*andTestFuzzyQuery: 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
updateDocumentspaths 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.
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 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 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.
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)withsearch(Q). Its probe on the stock main jar printed MISMATCH on 3 segments for kNN k = 2 (6 deleted vs 2 searched), the defaultFuzzyQuery(90 vs 50), TopTerms(5) (15 vs 5) andCommonTermsQuery(13 vs 40), and passed on 1 segment and for theTermQuerycontrol (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").
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub, JIRA, mailing lists, downstream repositories (automated) | 2026-10-28 | scripted 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 1 | 1655, 1276 and 1253 distinct hits; no report of this bug |
| GitHub (manual) | 2026-10-28 | 35 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-6357 | context 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 JIRA | 2026-10-28 | 10 web queries, 9 SOLR JIRA queries | unrelated results |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test 3 of 5 fail (-ea, -da); finder 6 of 7, independent reproducer 7 of 9; history probe |
branch_10_6 4ae946e5a9b | affected | finder's test 6 of 7; history probe |
| 9.0.0, 9.5.0, 9.12.3, 10.0.0, 10.5.2 | affected | kNN 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 | affected | fuzzy 60 vs 50, CommonTerms 3 vs 2, MLT 0 vs 6 |
| 4.0.0 | affected | fuzzy 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 | affected | default fuzzy deletes 1074 where search returns 1024; MLT 0 vs 6 |
| 2.3.2 | API absent | no deleteDocuments(Query): the probe does not compile |
| main + rewrite-once patch | partial fix | all repro tests pass; the buffered-segment path still mismatches |
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.
| Check | Verdict | What it found |
|---|---|---|
| lead check (planning verifier) | numbers re-run | Re-ran every number of the ideation probe on 3 segments; the 1-segment and TermQuery controls pass. |
| finder | candidate | JUnit 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 | procedural | OpenAI 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) | real | Own 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, medium | OpenAI 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, medium | The 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, medium | Re-ran the independent reproduction's JUnit with new seeds 1DEC0DE5 (-ea) and 1DEC0DE6 (-da): 7 of 9 fail. |
| Registration check and credibility audit | keep | Finder'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 checked | Gradle: 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 corrected | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 525 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,602 words
- 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 coreorg.apache.lucene.indextest 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 head4ae946e5a9b; 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.
- 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, 56lucene/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-1899lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:60; (at a9779ef3c193) FuzzyQuery.java:34-36lucene/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)
- TestDeleteByKnnQuery.java (JUnit test):
repro/LFV-904/src/org/apache/lucene/index/TestDeleteByKnnQuery.java(56 lines)
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.
assets/LFV-904/bug.png(525 KB)
