A set query can run on postings, on doc values, or keep only its top terms. That choice, its rewrite method, can change which documents match, but equality ignores it.
new TermInSetQuery("f", terms)matches documents whose fieldfhas any of the terms. Since 9.6.0 it is aMultiTermQuery, and its rewrite method decides how it runs: on the inverted index (postings, the default), on doc values (DOC_VALUES_REWRITE), or keeping only the top N terms.SortedSetDocValuesField.newSlowSetQuery("f", terms)is the doc-values variant: aTermInSetQuerywithDOC_VALUES_REWRITE. Its javadoc suggests using it next to the postingsTermInSetQuery, inside anIndexOrDocValuesQuery.- Lucene compares queries with
equalsandhashCodewherever it de-duplicates or caches them.IndexWriterkeeps buffered delete-by-query calls in aHashMapkeyed by the query until they are applied, for example at a flush, an NRT reopen or a commit.
f and term a: one runs on postings and matches 0 documents, one on doc values and matches 1. They compare equal, so IndexWriter's delete buffer keeps only the first, and the document stays live. Select the still to open it at full size. Generated with AI, checked against the code.- You index one document whose field
fhas only a doc value,SortedSetDocValuesField("f", "a"), with the defaultIndexWriterConfig. - You call
deleteDocuments(new TermInSetQuery("f", [a]), SortedSetDocValuesField.newSlowSetQuery("f", [a])). The first query reads postings and matches 0 documents (fhas none); the second reads doc values and matches 1. - You expect 0 live documents:
deleteDocumentsdeletes "the document(s) matching any of the provided queries". - You get 1. The two queries compare equal, because
TermInSetQuery.equalsandhashCodelook only at the field and terms, not at the rewrite method that decides how the query runs. IndexWriter buffers deletes in aHashMapkeyed by query, so it keeps the first query and runs only that one. - No error, and the result is committed. Two separate calls collide the same way when nothing applies the buffered deletes in between, and so do a top-terms rewrite (2 vs 1 matching documents) and any cache keyed on un-rewritten queries. Lucene's own query cache is not affected.
Applications passing two set queries with the same field and terms but different rewrite methods to deleteDocuments before the deletes are applied, or to their own query-keyed cache or the monitor module, when the two select different documents: a doc-values-only field, a field missing postings, or a top-terms rewrite. Default configuration; Lucene's search path is not affected; rare in practice.
Mechanism, terms and numbers724 words
Since 9.6.0 TermInSetQuery is a MultiTermQuery, and its rewrite method decides how it runs: on postings (the default), on doc values (DOC_VALUES_REWRITE, which SortedSetDocValuesField.newSlowSetQuery returns), or keeping only the top N terms. MultiTermQuery#equals and hashCode compare the rewrite method, but TermInSetQuery overrides both with a 2017 version that compares only the packed field and terms. So queries that match different documents compare equal: on a field with only doc values, new TermInSetQuery("f", [a]) and newSlowSetQuery("f", [a]) match 0 and 1 documents; on an ordinary field, new TermInSetQuery("f", [a, b]) and its TopTermsScoringBooleanQueryRewrite(1) variant match 2 and 1. Anything keyed on the un-rewritten query treats each pair as one. The main case inside Lucene is IndexWriter's delete-by-query buffer, a HashMap<Query, Integer> that keeps the first of two equal keys: with the default IndexWriterConfig, deleteDocuments with both doc-values-case queries leaves the document live, and in the other order a document added between two calls is deleted although no call matched it. The monitor module's QueryDecomposer also merges such a pair of disjuncts, so with presearcher filtering off a doc-values-only match is missed. Lucene's search path is not affected, because it compares rewritten queries. Affected: 9.6.0 to 10.6.0 and main; the fix is to delegate to MultiTermQuery#equals/hashCode, as the other core subclasses do.
- The rewrite method decides what matches.
TermInSetQuery extends MultiTermQuery(TermInSetQuery.java:73) and has a publicTermInSetQuery(RewriteMethod, String, Collection<BytesRef>)constructor (:87-92); the class javadoc invites custom rewrite methods "to define different execution behavior, such as relying on doc values ... or if scores are required" (:65-69).MultiTermQuery#rewriteis final:return rewriteMethod.rewrite(indexSearcher, this);(MultiTermQuery.java:325-327). - The parent compares it, the subclass does not.
MultiTermQuery#equals/hashCodeincluderewriteMethodandfield(MultiTermQuery.java:336-352).TermInSetQueryoverrides both:equalsissameClassAs(other) && equalsTo(...),equalsTocomparestermDataHashCodeandtermDataonly, andhashCodeis31 * classHash() + termDataHashCode(TermInSetQuery.java:203-217). It never calls the parent. Every other concreteMultiTermQueryoutside sandbox (AutomatonQuery, soWildcardQueryandRegexpQuery,PrefixQuery,FuzzyQuery,TermRangeQuery, joinTermsQuery) callssuper.equals, exceptMultiFacetQuery(facet module), which extendsTermInSetQueryand inherits its override. All of them but joinTermsQueryalso buildhashCodeonsuper.hashCode()(joinTermsQuery.java:127-129usesclassHash()). - The doc-values factory is such a query.
SortedSetDocValuesField.newSlowSetQueryreturnsnew TermInSetQuery(MultiTermQuery.DOC_VALUES_REWRITE, field, values)(SortedSetDocValuesField.java:130-132; the same inSortedDocValuesField.java:126-128).TermInSetQuery.newIndexOrDocValuesQuery(:104-110), used byKeywordField.newSetQuery, pairs a postings and a doc-valuesTermInSetQuerythat compare equal. - IndexWriter keys buffered deletes on the query.
deleteDocuments(Query...)(IndexWriter.java:1883-1898) reaches the delete queue, whoseQueryArrayNode.applycallsbufferedUpdates.addQuery(query, docIDUpto)for each query (DocumentsWriterDeleteQueue.java:462-472).BufferedUpdates.deleteQueriesis aHashMap<Query, Integer>(BufferedUpdates.java:64);addQuerydoesdeleteQueries.put(query, docIDUpto)(:110-116), which for an equal key keeps the first key object and replaces only its doc-id limit. - Only the first query runs.
FrozenBufferedUpdatescopies the keys (FrozenBufferedUpdates.java:93-99) and runs each once per segment (:388-420). The postings query matches nothing on a field without postings, so the document stays live. In the other order, the stored doc-values query takes the later limit and also deletes a document added between the two calls. - Search is not affected.
BooleanQueryrewrites its clauses before it removes duplicate FILTER and MUST_NOT clauses (BooleanQuery.java:299-362),LRUQueryCachekeys on the rewritten query (LRUQueryCache.java:834), andIndexOrDocValuesQuery.rewritecompares rewritten halves (IndexOrDocValuesQuery.java:131). In the executed cases the rewritten forms differ (different wrapper classes), so those keys stay distinct.
| pair (same field, same terms) | index | matching documents | equals |
|---|---|---|---|
new TermInSetQuery("f", [a]) vs newSlowSetQuery("f", [a]) | f has only doc values | 0 vs 1 | true |
default vs TopTermsScoringBooleanQueryRewrite(1), terms [a, b] | docs f=a, f=b | 2 vs 1 | true |
default vs SCORING_BOOLEAN_REWRITE | same | same documents, top score 1.0 vs 0.315 | true |
| calls before the next flush | expected | got |
|---|---|---|
deleteDocuments(postings, docValues) on a doc-values-only document | 0 live | 1 live |
commit; deleteDocuments(postings); deleteDocuments(docValues); commit | 0 live | 1 live |
top-terms(1) delete, then the default delete of p:(a b) | 0 live | 1 live |
| doc-values delete; add doc 2; postings delete (matches nothing) | doc 2 live | 0 live |
| controls: doc-values delete alone; a commit between the two deletes; an unequal second query | as expected | as expected |
Exposure and likelihood373 words
- What goes wrong. A delete that silently does not take effect, so the document stays searchable; or, in the other order, a document that no call matched is deleted. Both are committed. No error is raised.
- Configuration. The default
IndexWriterConfig, codec and NRT reader suffice. One call with both queries always collides. Two calls collide only if nothing applies the buffered deletes in between: a flush, an NRT reopen or a commit does, and so canIndexWriteritself when buffered deletes use too much heap (DocumentsWriter.java:153-166). A commit between the calls avoids it. - What it takes. Two
TermInSetQueryobjects with the same field and terms whose rewrite methods select different documents. With the documented pairing (postingsTermInSetQuerynext tonewSlowSetQuery) that needs a field that lacks postings or doc values on the documents concerned, for example a doc-values-only field or documents indexed before a schema change. With a top-terms or scoring rewrite an ordinary field is enough. - Other consumers. An application cache or set keyed on un-rewritten
Queryobjects serves one query's result for the other (an independent reproducer: a map returned 2 for the top-1 query, whose true count is 1). Wrapper equality inherits it: aBooleanQuerywith a FILTER clause of either variant compares equal. The monitor module'sQueryDecomposerkeeps the un-rewritten clauses of a registered query in sets (QueryDecomposer.java:77-102): aBooleanQuerywith both variants as SHOULD clauses decomposes into 1 part, not 2, and withPresearcher.NO_FILTERINGa document whose field has only the doc value gets 0 matches where a direct search counts 1 (executed on main; with the fix 2 parts and 1 match). With the default presearcher both builds give 0, because it does not select doc-values-only fields. - Not affected, of the consumers checked. Lucene's search path:
BooleanQueryde-duplication,LRUQueryCacheandIndexOrDocValuesQuerycompare rewritten queries, which differ in the executed cases (impact probes on main).MultiFacetQueryinherits the override but always uses the default rewrite method. Other in-tree code that keeps un-rewritten queries in sets or maps was not audited. - Likelihood. Contrived for the delete path: no caller doing this is known, and the recorded searches found no report since 9.6.0 (2023). Index size does not matter; the repro has one document.
One JUnit class, one method, public API only: one document whose field f has only a doc value, the default IndexWriterConfig, one deleteDocuments call with the postings and the doc-values set query, then an NRT reader and an assertion of 0 live documents. Fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.SortedSetDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestTermInSetQueryDeleteByDocValuesSetQuery extends LuceneTestCase {
public void testDeleteByPostingsAndDocValuesSetQuery() throws Exception {
List<BytesRef> terms = List.of(new BytesRef("a"));
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new SortedSetDocValuesField("f", new BytesRef("a")));
w.addDocument(doc);
// expected 0 live docs, actual 1: the two queries compare equal, so only the first is applied
w.deleteDocuments(
new TermInSetQuery("f", terms), SortedSetDocValuesField.newSlowSetQuery("f", terms));
try (DirectoryReader reader = DirectoryReader.open(w)) {
assertEquals("live docs after deleteDocuments(q1, q2)", 0, reader.numDocs());
}
}
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTermInSetQueryDeleteByDocValuesSetQuery -Ptests.seed=DEADBEEF
- Expected
- The doc-values query matches the document, so
reader.numDocs()is 0. - Observed
live docs after deleteDocuments(q1, q2) expected:<0> but was:<1>: only the postings query, which matches nothing, was applied.
More files, environment, full output202 words
The class sits in org.apache.lucene.search, next to TestTermInSetQuery, and uses nothing package-private. The test framework's random directory and codec do not matter: 20 of 20 derived seeds fail the same way. It compiles on 9.10.0 and later (the newSlowSetQuery(String, Collection) overload); on 9.6.0 to 9.9.x use the BytesRef... overloads instead.
Regression test: this same test, as written. With the fix it passes, together with the core format, ECJ and forbidden-apis checks.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0: fail; with either fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain Java logic) |
cp $REPRO/TestTermInSetQueryDeleteByDocValuesSetQuery.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTermInSetQueryDeleteByDocValuesSetQuery -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestTermInSetQueryDeleteByDocValuesSetQuery > testDeleteByPostingsAndDocValuesSetQuery FAILED
java.lang.AssertionError: live docs after deleteDocuments(q1, q2) expected:<0> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:81CC3BFBF12E5807]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.search.TestTermInSetQueryDeleteByDocValuesSetQuery.testDeleteByPostingsAndDocValuesSetQuery(TestTermInSetQueryDeleteByDocValuesSetQuery.java:42)
# standalone runner, seed DEADBEEF: 1 of 1 failed, same message, on
main 645d4fe14a20 (-ea and -da), 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0
# with the recommended fix: the test passes (assertions on and off)deleteDocuments(Query...)promises to delete "the document(s) matching any of the provided queries"; the doc-values query matches, yet the document stays.MultiTermQuery.equalsandhashCodeinclude the rewrite method, and every sibling subclass'sequalscalls the parent's.- The terms-only
equalspredates rewrite methods (2017); the 9.6.0 change that added them left it untouched.
The full argument324 words
- The delete contract.
IndexWriter.deleteDocuments(Query...): "Deletes the document(s) matching any of the provided queries" (IndexWriter.java:1875). The repro asserts exactly this, so it needs no argument about whatequalsshould return. - The equality contract.
Query#equalsis "required so that QueryCache works properly", and "Typically a query will be equal to another only if it's an instance of the same class and its document-filtering properties are identical to those of the other instance" (Query.java:96-107). Here they differ: 0 vs 1 and 2 vs 1 matching documents. - The parent and the siblings.
MultiTermQuery#equals/hashCodeinclude the rewrite method (MultiTermQuery.java:336-352), and every other concrete subclass outside sandbox callssuper.equals, exceptMultiFacetQuery, which inheritsTermInSetQuery's override (the core siblings also callsuper.hashCode; joinTermsQuerydoes not). ForBlendedTermQuery, a test requires two queries that differ only in their rewrite method to be unequal (TestBlendedTermQuery.java:39-49). - History. The terms-only
equalsdates from LUCENE-7624 (2017), whenTermInSetQuery extends Queryhad no rewrite method. #12156 (9.6.0) made it aMultiTermQuerywith a public rewrite-method constructor and changed no line ofequals/hashCode; its discussion never mentions equality. Before #12175 (9.6.0) the doc-values set query was a separate class that never compared equal. - Maintainer statements and precedent. LUCENE-10431 (#11467) kept the rewrite method in
MultiTermQuery#equals(9.x left it out ofhashCodeonly, because it was mutable there). In LUCENE-7810 (#8861), a false-positive query equality fixed as a bug, Adrien Grand wrote, about taking the score mode into account in the join query'sequals, that "two queries should only be equal if they matche [sic] the same docs and give them the same scores" (2017-05-16). Similar omissions were fixed as bugs: LUCENE-10026 (CombinedFieldQuery), LUCENE-10552 (KnnVectorQuery), #12260 (SynonymQuery). - Nothing pins the current behaviour.
TestTermInSetQuery's equality tests use only the default rewrite method (TestTermInSetQuery.java:315-376); no CHANGES or MIGRATE entry describes it.
The devil's-advocate check217 words
The strongest case for "intended" (a devil's-advocate verifier and a cross-model review): the rewrite method is an execution strategy. On a field indexed with the same values in postings and doc values, the layout KeywordField and IndexOrDocValuesQuery assume, the default, doc-values and constant-score rewrites match the same documents, so treating them as equal is harmless and lets the two halves of newIndexOrDocValuesQuery compare equal. Query#equals says only "Typically"; Lucene's own consumers compare rewritten queries; and 9.x once took the rewrite method out of MultiTermQuery#hashCode (LUCENE-10431).
Why it fails: TermInSetQuery's own javadoc offers rewrite methods that change matches and scores, not only execution (top-terms(1): 2 vs 1 documents; SCORING_BOOLEAN_REWRITE: top score 1.0 vs 0.315). A doc-values-only field is a legal use of newSlowSetQuery, and there the "same documents" premise fails (1 vs 0). The deleteDocuments contract breaks however one reads "Typically". LUCENE-10431 kept the rewrite method in equals and left it out of the 9.x hashCode only because it was mutable there; on main it is final and in both. The independent reproducer, the devil's-advocate verifier and the intended-lens review returned "real" (the reading-only refute review's "not real" was procedural: it could not run Java); the adjudication confirmed with high confidence. The practical reach is narrow, so a maintainer may still rate it low priority.
Recommended Delegate to MultiTermQuery#equals and hashCode (recommended, validated)
Let TermInSetQuery.equals and hashCode call MultiTermQuery's, as the other core subclasses do, so the rewrite method (and the field) count (+3/-3 lines). Validated: the repro passes, 2,838 core search and document tests, the monitor, facet and join modules and the core lint checks pass. On 9.x the same change keeps the mutable rewrite method out of hashCode.
@Override
public boolean equals(Object other) {
// super.equals checks the class, the field and the rewrite method
return super.equals(other) && equalsTo(getClass().cast(other));
}
@Override
public int hashCode() {
return 31 * super.hashCode() + termDataHashCode;
}All options and trade-offs3 options · 503 words
Both fixes that make equality include the rewrite method were validated through gradle in a scratch worktree of 645d4fe14a20; the recommended one is the idiomatic form. A change to the delete buffer instead was not built.
Call super.equals(other) instead of sameClassAs(other), and build hashCode on super.hashCode() instead of classHash(), as PrefixQuery, AutomatonQuery, FuzzyQuery and TermRangeQuery do. The parent checks the class, the field and the rewrite method.
@Override
public boolean equals(Object other) {
// super.equals checks the class, the field and the rewrite method
return super.equals(other) && equalsTo(getClass().cast(other));
}
@Override
public int hashCode() {
return 31 * super.hashCode() + termDataHashCode;
}Trade-offs
- Side effect. The two halves of
TermInSetQuery.newIndexOrDocValuesQuery(and so ofKeywordField.newSetQuery) stop comparing equal, which restores the pre-9.6 distinction between the doc-values and the postings set query. Nothing in-tree relies on their equality;IndexOrDocValuesQuery.rewritecompares rewritten halves, which already differ. - Cost. One extra rewrite-method and field comparison per
equals, before the term data. The built-in rewrite methods are singletons or value-equal, so hash codes stay stable. A customRewriteMethodwithoutequalsmakes queries built with different instances unequal (cache misses only), as for every otherMultiTermQuerytoday. - 9.x. There
MultiTermQuery#hashCodedeliberately leaves out the then-mutable rewrite method whileequalskeeps it (LUCENE-10431), so delegating gives the right result on 9.x without a special backport (read, not run on 9.x).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: it passes with assertions on and off. - The repro plus the 9 closest suites (
TestTermInSetQuery,TestIndexOrDocValuesQuery,TestMultiTermQueryRewrites,TestDocValuesRewriteMethod,TestMultiTermConstantScore,TestBlendedTermQuery,TestSortedSetDocValuesSetQuery,TestKeywordField,TestIndexWriterDelete): 73 tests, 8 skipped (nightly or monster), 0 failures. The nightlyTestTermInSetQuery.testDuelandTestIndexOrDocValuesQuerywith-Ptests.nightly=true: 20 tests, 0 failures. :lucene:core:testoverorg.apache.lucene.search.*andorg.apache.lucene.document.*: 2,838 tests, 75 skipped, 0 failures;:lucene:monitor:test(181),:lucene:facet:test(306),:lucene:join:test(104): 0 failures.checkGoogleJavaFormat,ecjLintMain,ecjLintTest(with the repro test in place),forbiddenApisMain,forbiddenApisTest: pass.
Add rewriteMethod.equals(other.rewriteMethod) to equalsTo and rewriteMethod.hashCode() to hashCode, keeping the field implicit (it is encoded in the term data).
return termDataHashCode == other.termDataHashCode
&& rewriteMethod.equals(other.rewriteMethod)
&& termData.equals(other.termData);
...
return 31 * (31 * classHash() + termDataHashCode) + rewriteMethod.hashCode();Trade-offs
Same effect on main, but it duplicates the parent's logic, and a 9.x backport must leave the rewrite method out of hashCode by hand.
What the checks validated
Gradle on 645d4fe14a20: the repro plus TestTermInSetQuery, TestIndexOrDocValuesQuery, TestKeywordField and TestSortedSetDocValuesSetQuery: 30 tests, 2 skipped, 0 failures. Earlier also checked by the finder, the independent reproducer, the adjudication and the repro minimization.
Make BufferedUpdates keep every delete query object (an identity map or a list).
Trade-offs
Fixes only the delete path, not application caches or wrapper equality, and loses the de-duplication of genuinely equal delete queries.
Code outside Lucene that keys caches or de-duplicating sets on un-rewritten Query objects aliases the two variants the same way and can serve one query's hits for the other; Lucene's own query cache keys on rewritten queries and is not affected.
A scan of the bytecode of every concrete Lucene query class found that TermInSetQuery's equality never reads the rewrite method its rewrite() uses. A bounded-exhaustive equality harness then found equal pairs that match different documents, and a security screen found the delete-by-query consequence.
The method in full149 words
Method: semi-formal (a static field-set scan plus bounded-exhaustive testing, then public-API JUnit tests).
- A hunt unit on query object contracts scanned the bytecode of all 142 concrete
Queryclasses in the main jars and listed, for each class, the fields that other methods, such asrewrite(), read butequals/hashCodedo not.MultiTermQuery.rewriteMethodwas read byrewrite()but not byTermInSetQuery.equals/hashCode. - The unit's bounded-exhaustive equality-congruence harness ran small queries on small indexes and found equal
TermInSetQuerypairs with different matching documents, different scores, and unequal rewritten forms. The finder reduced it to a JUnit on the public API. - An independent reproducer, a devil's-advocate verifier (who added the doc-values-only case), cross-model reviews (OpenAI GPT-5.6, reading only) and an adjudication checked it. The security screen then executed the
IndexWriter.deleteDocumentsconsequence, the follow-up re-ran it with the default configuration, and the repro was minimized to one test.
No prior report found in the recorded searches (searched on 2026-10-26 over GitHub, commits, the mailing lists, JIRA, downstream projects and the web; GitHub rechecked on 2026-10-31). Closest: an open 2023 draft that would drop the override as a side effect, and a 2018 question about the same omission in SpanMultiTermQueryWrapper.
Searches3 searches
Verdict new: no issue, PR, commit, list thread, JIRA entry, downstream issue or web page describes TermInSetQuery equality ignoring its rewrite method, newSlowSetQuery comparing equal to the postings TermInSetQuery, or the delete-by-query consequence. Related, not reports: #12312 (open 2023 draft "TermInSetQuery extends AutomatonQuery"; merged as is it would inherit MultiTermQuery equality, but it never mentions equality), LUCENE-6513 (#7571; in 2018 David Smiley asked whether SpanMultiTermQueryWrapper.equals should consider the rewrite method: same defect class, other class), #12156 and #12175 (origin), #14425 / #14435 (newIndexOrDocValuesQuery), LUCENE-10431 and LUCENE-7810 (intent and precedent). Upstream main 2f424fd428da (2026-10-30) still has the terms-only equals.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, JIRA, web | 2026-10-26 | 133 logged queries: a scripted prior-report search (51 queries, 30 of its list, JIRA and web queries run a second time while GitHub search was rate-limited), 63 GitHub GraphQL searches (42 on apache/lucene, 21 downstream), 10 list-archive and 6 JIRA REST queries, the changed files of all 411 open PRs and the diffs of those touching TermInSetQuery or MultiTermQuery, the commit history; 27 items read in full | no report; related #12312, #7571 (LUCENE-6513), #12156, #12175, #14425 / #14435, #11467 (LUCENE-10431), #8861 (LUCENE-7810) |
| GitHub (delete-by-query consequence) | 2026-10-26 | 1 REST and 3 GraphQL searches | no hit, or unrelated 2008-2010 items |
| GitHub (recheck) | 2026-10-31 | "TermInSetQuery equals rewrite", "TermInSetQuery hashCode", "newSlowSetQuery equals", "deleteDocuments TermInSetQuery", "rewrite method" equals TermInSetQuery in apache/lucene issues and PRs; the file's commit history on upstream main | only known unrelated items (#16051, #14349, #16394, #12135, #9771) or no hits; last commit on the file 54283150a81 (2026-06-14, build-only) |
Introduced by #12156 (3809106602a, 2023-03-01), which made TermInSetQuery a MultiTermQuery with rewrite methods and kept its 2017 terms-only equals; #12175 the same day made newSlowSetQuery return such a query. First released in 9.6.0; 9.6.0 to 10.6.0 and main are affected; not fixed.
Versions tested6 rows
Introduced by apache/lucene#12156, "Remove custom TermInSetQuery implementation in favor of extending MultiTermQuery" (gsmiller, merged 2023-03-01, milestone 9.6.0): main 3809106602a, 9.x fa1d050df1d. The bug is an omission: the PR changed no line of equals/equalsTo/hashCode, whose text comes from LUCENE-7624 (22940f5c492, 2017), a LUCENE-7637 comment (43874fc5b5c) and a 2020 reformat (2d6ad2fee6d).
The doc-values trigger comes from #12175 (main 569533bd76a, 9.x 5ff8ea4952a, also 2023-03-01): newSlowSetQuery became new TermInSetQuery(DOC_VALUES_REWRITE, ...). In 9.5.0 the doc-values set query was the sandbox DocValuesTermsQuery, a different class that never compared equal.
The release tags 9.6.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 have the same equalsTo and hashCode text (the follow-up checked every tag from 9.6.0 to 10.5.2). On 9.6.0 to 9.12.3 the deprecated MultiTermQuery#setRewriteMethod is a second way to change the rewrite method. Upstream main 2f424fd428da (2026-10-30) is unchanged.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (1 live document, expected 0) |
| 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.10.0 | affected | minimal repro fails, same message |
| 9.6.0 | first | the equality repros fail: top-terms pair 2 vs 1 documents and doc-values pair 1 vs 0, both equal (the minimal repro needs the 9.10.0 overload) |
main ea57360912ed, a9779ef3c193, 645d4fe14a20; 10.5.2 | affected | delete-by-query probe with the default configuration fails 3 of 3 (assertions on and off on ea57360912ed, on elsewhere); its 4 controls pass on ea57360912ed and 645d4fe14a20 |
| 9.5.0 | not affected | TermInSetQuery extends Query and has no rewrite method; the doc-values set query is a different class |
with the recommended fix (645d4fe14a20) | not affected | repro passes; core search and document tests, monitor, facet and join pass |
An independent reproducer, a devil's-advocate verifier and a cross-model review all said real; the adjudication confirmed with high confidence. A second cross-model review returned a procedural "not real" because, reading only, it could not run Java. The delete path, found later, was executed by the security screen, the follow-up and this doc.
Checks and verdicts412 words
Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with identical files involved) or on releases.
| Check | Verdict | What it found |
|---|---|---|
| Finder (JUnit) | candidate | Top-terms(1) pair: 2 vs 1 documents, equal; fails 2 of 3 on main with assertions on and off, 10.5.2 and 9.12.3; the PrefixQuery control passes; the fix passes. |
| Cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real (low), procedural | Could not run Java, so "not reproduced" by construction; agreed with the root cause and that the inputs are legal; a separate re-run of the finder's test (not the reviewer's) failed 2 of 3. |
| Cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real, medium | Rated the intended-behaviour case moderate and rebutted it; separate re-runs fail on main, 10.5.2 and 9.12.3 and pass with the fix. |
| Independent reproducer | real, minor | Own JUnit fails 3 of 5 on main, 10.5.2, 9.12.3 and 9.6.0 (scores 1.0 vs 0.315 too); the fix passes and 27 related upstream tests pass; impact probe: search path correct, a user map keyed on Query wrong. |
| Devil's advocate | real | Added the doc-values-only case (1 vs 0 documents, equal queries), which needs no unusual rewrite method; fails on main and 10.5.2. |
| Adjudication | confirmed, high | Re-ran the finder's, the independent reproduction's and the intended-behaviour check's tests with new seeds and the independent reproduction's with the fix. |
| Security screen and follow-up | delete path executed | deleteDocuments drops one of two equal delete queries: fails on main and 10.5.2, also with the default configuration; controls pass; with the fix the three delete tests pass (only the test that characterizes the affected behaviour flips). Not security-sensitive on re-evaluation. |
| Repro minimization | verified | The one-test repro fails on 645d4fe14a20 with assertions on and off (20 of 20 seeds) and passes with the fix. |
| This doc (gradle, fixes) | reproduced, fix validated | Repro fails on 645d4fe14a20 and on 9.10.0 to 10.6.0; both fixes pass it; the recommended one passes the broad core, monitor, facet and join suites and the lint checks. |
| Fact checks (independent, cross-model, diagram) | verified, wording corrected | An independent fact check re-ran the repro (main with assertions on and off, 10.6.0, 9.10.0, 20 of 20 iterations, the fix), the gradle commands, the delete probe and its controls on 645d4fe14a20, and added the monitor-module case. A cross-model fact check (OpenAI GPT-5.6, reading only) and a diagram check followed. Their corrections are applied. |
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 503 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,403 words
- Registry title
- TermInSetQuery.equals/hashCode ignore the rewrite method, so set queries that match different documents compare equal, and IndexWriter's delete-by-query buffer then applies only one of them
- Area
- core / search: TermInSetQuery equality (MultiTermQuery rewrite methods); consequence in index / IndexWriter delete-by-query buffering
- Priority
- Low, rank 846: TermInSetQuery equality ignores the rewrite method and can merge two different delete-by-query calls, but only when differing rewrites select different documents within one flush window, so it heads the narrow equality rows above LFV-216.
- Impact class
- data corruption
- Impact note
- Applications that build TermInSetQuery objects for the same field and terms with different rewrite methods (most naturally new TermInSetQuery next to SortedSetDocValuesField.newSlowSetQuery, which is a DOC_VALUES_REWRITE TermInSetQuery, on a field that lacks postings or doc values on some documents, or a top-terms or scoring rewrite) and hand both to something that keys on un-rewritten queries are hit: IndexWriter.deleteDocuments(Query...) keeps only the first of the two within one flush window (executed with the default IndexWriterConfig on main -ea and -da and on 10.5.2: a delete that silently does not take effect, or a later document deleted that no call matched), and an application cache keyed on Query serves one query's result for the other; Lucene's own search-time consumers (BooleanQuery clause de-duplication, LRUQueryCache) compare rewritten queries and are not affected; ordinary scale; released 9.6.0 to 10.6.0.
- Summary
- Since 9.6.0 TermInSetQuery is a MultiTermQuery whose rewrite method decides which documents it matches and how it scores, but its equals/hashCode still compare only the field and terms: new TermInSetQuery("f", [a]) equals SortedSetDocValuesField.newSlowSetQuery("f", [a]) although on a field with only doc values they match 0 and 1 documents, and on an ordinary field new TermInSetQuery("f", [a, b]) equals its TopTermsScoringBooleanQueryRewrite(1) variant although they match 2 and 1 documents. You expect queries that match different documents to be unequal, as PrefixQuery, AutomatonQuery, FuzzyQuery and TermRangeQuery are when only the rewrite method differs. Instead anything keyed on the un-rewritten query treats the two as one: IndexWriter.deleteDocuments with both queries in one flush window applies only the first, so a doc-values delete silently does not happen, or a document added in between is deleted although no call matched it.
- Introduced
- 3809106602a9675f4fd217b1090af4505d4ec2a7 on main and its 9.x backport fa1d050df1d40f80f9dd17aeed1614bd05fbac83, apache/lucene#12156 by gsmiller, 'Remove custom TermInSetQuery implementation in favor of extending MultiTermQuery' [read: the PR made TermInSetQuery extend MultiTermQuery and added the public TermInSetQuery(RewriteMethod, ...) constructors, but changed no line of the 2017 termData-only equals/equalsTo/hashCode (22940f5c492, LUCENE-7624; 43874fc5b5c, LUCENE-7637 comment; 2d6ad2fee6d, 2020 reformat); exec: 9.5.0 has no such API (finder repro compile_error, history probe 'API absent'), 9.6.0 fails]. The doc-values trigger comes from #12175 (main 569533bd76a, 9.x 5ff8ea4952a), which made SortedSetDocValuesField.newSlowSetQuery return new TermInSetQuery(DOC_VALUES_REWRITE, ...) [read] (2023-03-01 (commit dates of 3809106602a, fa1d050df1d and the #12175 commits; the 9.6.0 tag commit is dated 2023-05-03))
- First affected version
- 9.6.0 [exec] (9.5.0 is not affected: TermInSetQuery extends Query and has no rewrite method there; the finder repro does not compile and the history probe reports the API absent [exec])
- Affected versions
- 9.6.0 to 9.12.3, 10.0.0 to 10.6.0 and main ea57360912ed and 645d4fe14a20. [exec] the minimal test fails on main 645d4fe14a20 (-ea and -da) and on the 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.10.0 release jars (-ea), 2026-10-31; the finder's JUnit fails 2 of 3 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.6.0, and so does the version-portable history probe; the independent reproduction's independent JUnit fails 3 of 5 on main (-ea and -da), 10.5.2, 9.12.3 and 9.6.0; the intended-behaviour check's doc-values control fails 2 of 2 on main (-ea and -da) and 10.5.2; the delete-by-query probe fails 3 of 8 on main (-ea and -da) and 10.5.2, and its default-IndexWriterConfig variant 3 of 3 on main (-ea and -da) and 10.5.2 (not run on 9.x, whose newSlowSetQuery has no Collection overload). [read] 9.7.0 to 9.12.2 and 10.1.0 to 10.5.1: equals/equalsTo/hashCode are identical in every release tag and never read the rewrite method; on 9.x the deprecated MultiTermQuery#setRewriteMethod is a second way to change it. Not fixed: unchanged on upstream main, branch_10x, branch_10_6 and branch_9_12 (2026-10-26) [read].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new: 133 logged queries: a scripted prior-report search over apache/lucene issues and PRs (16), commits (3), Elasticsearch, OpenSearch and Solr (2), the dev@, java-user@, issues@ and commits@ lists (20), JIRA (4) and the web (6); 63 GitHub GraphQL searches (42 apache/lucene, 21 downstream); 10 direct list-archive and 6 JIRA REST queries; the changed-file lists of all 411 open apache/lucene PRs and the diffs of those that touch TermInSetQuery.java or MultiTermQuery.java; the upstream main file history and the local full history; 27 GitHub items read in full; plus the finder's, the intended-behaviour check's and the adjudication's searches). This follow-up added 1 GitHub REST and 3 GraphQL searches for the delete-by-query consequence (2026-10-26: 0 hits, or only unrelated 2008-2010 LUCENE items). Not fixed upstream: TermInSetQuery equals/hashCode are unchanged on upstream main (last commit on the file 54283150a81, build-only), branch_10x, branch_10_6 and branch_9_12, and no open PR fixes it on purpose. Related items, not reports of this bug: #12312 (open 2023 draft 'TermInSetQuery extends AutomatonQuery'; it would drop the override as a side effect and never mentions equality), #12156 and #12175 (origin), #14425 / #14435 (newIndexOrDocValuesQuery, whose two halves compare equal today), #11467 (LUCENE-10431: the rewrite method kept in MultiTermQuery equals), #7571 (LUCENE-6513: a 2018 review question whether SpanMultiTermQueryWrapper.equals should consider the rewrite method; same defect class, different class and fix site), #8861 (LUCENE-7810, an earlier false-positive query equality fixed as a bug), #12135, #12089, #11244, #14986 / #15441, #16051, #16801, #709
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree (assertions on and off) and through a standalone runner, which also fails it on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.10.0. Both fixes pass it; the recommended one also passes 2,838 core search and document tests, the monitor, facet and join modules and the core format, ECJ and forbidden-apis checks. The hunt, the verifiers and the adjudication ran mainea57360912ed, a refresh re-ran the repros ona9779ef3c193; the files involved are identical in all three.
- Finding record
lucene/core/src/java/org/apache/lucene/search/TermInSetQuery.java:64-71 (class javadoc invites custom rewrite methods: DOC_VALUES_REWRITE, SCORING_BOOLEAN_REWRITE), :87-92 (public TermInSetQuery(RewriteMethod, String, Collection) constructor), :203-217 (equals/equalsTo/hashCode compare termDataHashCode and termData only)lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:325-352 (rewrite() uses the final rewriteMethod; hashCode and equals include rewriteMethod and field)lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (equals is 'required so that QueryCache works properly'; 'Typically a query will be equal to another only if ... its document-filtering properties are identical')lucene/core/src/java/org/apache/lucene/document/SortedSetDocValuesField.java:130-132 and SortedDocValuesField.java:126-128 (newSlowSetQuery returns new TermInSetQuery(MultiTermQuery.DOC_VALUES_REWRITE, field, values)); KeywordField.java:174-179 and TermInSetQuery.java:94-110 (newSetQuery / newIndexOrDocValuesQuery halves)lucene/core/src/java/org/apache/lucene/index/BufferedUpdates.java:64, 110-115 (deleteQueries is a HashMap<Query, Integer>; addQuery does put(query, docIDUpto), which keeps the first key and replaces its limit), FrozenBufferedUpdates.java:93-99, 388-420 (each stored query applied once), IndexWriter.java:1874-1883 (deleteDocuments(Query...): 'Deletes the document(s) matching any of the provided queries')lucene/facet/src/java/org/apache/lucene/facet/MultiFacetQuery.java:34-49 (extends TermInSetQuery with the default rewrite method only)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/TermInSetQuery.java:65-69, :73, :87-92, :104-110, :203-217 (main 645d4fe14a20)lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:325-327, :336-352lucene/core/src/java/org/apache/lucene/document/SortedSetDocValuesField.java:120-132; document/SortedDocValuesField.java:126-128; document/KeywordField.java:174-179lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1874-1898; index/DocumentsWriter.java:141-143; index/DocumentsWriterDeleteQueue.java:462-472; index/BufferedUpdates.java:64, :110-116; index/FrozenBufferedUpdates.java:93-99, :388-420lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:299-362; search/LRUQueryCache.java:834; search/IndexOrDocValuesQuery.java:131lucene/join/src/java/org/apache/lucene/search/join/TermsQuery.java:112, :127-129 (super.equals; hashCode without super.hashCode); lucene/facet/src/java/org/apache/lucene/facet/MultiFacetQuery.java:34-49 (extends TermInSetQuery)
- Who is affected
lucene/facet/src/java/org/apache/lucene/facet/MultiFacetQuery.java:34-49 (default rewrite method only)lucene/core/src/java/org/apache/lucene/index/DocumentsWriter.java:153-166 (each delete may apply all buffered deletes when they use too much heap)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1874-1882; search/Query.java:96-107; search/MultiTermQuery.java:54, :336-352lucene/core/src/test/org/apache/lucene/search/TestBlendedTermQuery.java:39-49; lucene/core/src/test/org/apache/lucene/search/TestTermInSetQuery.java:315-376
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/PrefixQuery.java:79-92; search/AutomatonQuery.java:113-132 (siblings call super)a Lucene clone releases/lucene/9.12.3: lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:345-364 (9.x hashCode leaves the rewrite method out, equals keeps it)lucene/core/src/java/org/apache/lucene/search/DocValuesRewriteMethod.java:194-202; search/TopTermsRewrite.java:176-188 (built-in rewrite methods are singletons or value-equal); search/IndexOrDocValuesQuery.java:131
- Prior reports
https://github.com/apache/lucene/pull/12312, https://github.com/apache/lucene/issues/7571, https://github.com/apache/lucene/pull/12156, https://github.com/apache/lucene/pull/12175
- History
a Lucene clone release tags 9.6.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (TermInSetQuery.java equalsTo/hashCode; SortedSetDocValuesField.newSlowSetQuery)
- TestTermInSetQueryDeleteByDocValuesSetQuery.java (one test class):
repro/LFV-321/src/org/apache/lucene/search/TestTermInSetQueryDeleteByDocValuesSetQuery.java(46 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the repro's output.
assets/LFV-321/bug.png(503 KB)
