All findings · Lucene finding · LFV-321 · terminsetquery-equals-ignores-rewrite-method

TermInSetQuery.equals ignores the rewrite method, so set queries that match different documents compare equal and IndexWriter drops one of two such deletes

Two TermInSetQuery objects with the same field and terms compare equal even when their rewrite methods make them match different documents. IndexWriter.deleteDocuments then runs only one of them: deleting a doc-values-only document with a postings set query plus newSlowSetQuery leaves it live (1 document, expected 0).

Summary

Priority
Low, rank 846TermInSetQuery 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
data corruption
Since
9.6.0 (#12156, 2023)introduced 2023-03-01 · about 3 years
Affected
9.6.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: a bytecode scan of query equality and a bounded-exhaustive harness, then a JUnit

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

The finding in 30 seconds

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.

Set queries and their rewrite method

  1. new TermInSetQuery("f", terms) matches documents whose field f has any of the terms. Since 9.6.0 it is a MultiTermQuery, 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.
  2. SortedSetDocValuesField.newSlowSetQuery("f", terms) is the doc-values variant: a TermInSetQuery with DOC_VALUES_REWRITE. Its javadoc suggests using it next to the postings TermInSetQuery, inside an IndexOrDocValuesQuery.
  3. Lucene compares queries with equals and hashCode wherever it de-duplicates or caches them. IndexWriter keeps buffered delete-by-query calls in a HashMap keyed by the query until they are applied, for example at a flush, an NRT reopen or a commit.
Diagram of the bug: one document whose field f has only the doc value a. deleteDocuments receives two set queries for f and a: new TermInSetQuery, which reads postings and matches 0 documents, and SortedSetDocValuesField.newSlowSetQuery, which reads doc values and matches 1. TermInSetQuery.equals compares only the field and terms, so the two are equal, and IndexWriter's delete buffer, a map keyed by query, keeps only the first. The document stays live: 1 live document, expected 0.
Two set queries for field 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.

The finding, step by step

  1. You index one document whose field f has only a doc value, SortedSetDocValuesField("f", "a"), with the default IndexWriterConfig.
  2. You call deleteDocuments(new TermInSetQuery("f", [a]), SortedSetDocValuesField.newSlowSetQuery("f", [a])). The first query reads postings and matches 0 documents (f has none); the second reads doc values and matches 1.
  3. You expect 0 live documents: deleteDocuments deletes "the document(s) matching any of the provided queries".
  4. You get 1. The two queries compare equal, because TermInSetQuery.equals and hashCode look only at the field and terms, not at the rewrite method that decides how the query runs. IndexWriter buffers deletes in a HashMap keyed by query, so it keeps the first query and runs only that one.
  5. 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.

Who is 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 bug (main 645d4fe14a20)

  1. The rewrite method decides what matches. TermInSetQuery extends MultiTermQuery (TermInSetQuery.java:73) and has a public TermInSetQuery(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#rewrite is final: return rewriteMethod.rewrite(indexSearcher, this); (MultiTermQuery.java:325-327).
  2. The parent compares it, the subclass does not. MultiTermQuery#equals/hashCode include rewriteMethod and field (MultiTermQuery.java:336-352). TermInSetQuery overrides both: equals is sameClassAs(other) && equalsTo(...), equalsTo compares termDataHashCode and termData only, and hashCode is 31 * classHash() + termDataHashCode (TermInSetQuery.java:203-217). It never calls the parent. Every other concrete MultiTermQuery outside sandbox (AutomatonQuery, so WildcardQuery and RegexpQuery, PrefixQuery, FuzzyQuery, TermRangeQuery, join TermsQuery) calls super.equals, except MultiFacetQuery (facet module), which extends TermInSetQuery and inherits its override. All of them but join TermsQuery also build hashCode on super.hashCode() (join TermsQuery.java:127-129 uses classHash()).
  3. The doc-values factory is such a query. SortedSetDocValuesField.newSlowSetQuery returns new TermInSetQuery(MultiTermQuery.DOC_VALUES_REWRITE, field, values) (SortedSetDocValuesField.java:130-132; the same in SortedDocValuesField.java:126-128). TermInSetQuery.newIndexOrDocValuesQuery (:104-110), used by KeywordField.newSetQuery, pairs a postings and a doc-values TermInSetQuery that compare equal.
  4. IndexWriter keys buffered deletes on the query. deleteDocuments(Query...) (IndexWriter.java:1883-1898) reaches the delete queue, whose QueryArrayNode.apply calls bufferedUpdates.addQuery(query, docIDUpto) for each query (DocumentsWriterDeleteQueue.java:462-472). BufferedUpdates.deleteQueries is a HashMap<Query, Integer> (BufferedUpdates.java:64); addQuery does deleteQueries.put(query, docIDUpto) (:110-116), which for an equal key keeps the first key object and replaces only its doc-id limit.
  5. Only the first query runs. FrozenBufferedUpdates copies 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.
  6. Search is not affected. BooleanQuery rewrites its clauses before it removes duplicate FILTER and MUST_NOT clauses (BooleanQuery.java:299-362), LRUQueryCache keys on the rewritten query (LRUQueryCache.java:834), and IndexOrDocValuesQuery.rewrite compares rewritten halves (IndexOrDocValuesQuery.java:131). In the executed cases the rewritten forms differ (different wrapper classes), so those keys stay distinct.

Equal queries, different documents

pair (same field, same terms)indexmatching documentsequals
new TermInSetQuery("f", [a]) vs newSlowSetQuery("f", [a])f has only doc values0 vs 1true
default vs TopTermsScoringBooleanQueryRewrite(1), terms [a, b]docs f=a, f=b2 vs 1true
default vs SCORING_BOOLEAN_REWRITEsamesame documents, top score 1.0 vs 0.315true

What IndexWriter does with them (default IndexWriterConfig)

calls before the next flushexpectedgot
deleteDocuments(postings, docValues) on a doc-values-only document0 live1 live
commit; deleteDocuments(postings); deleteDocuments(docValues); commit0 live1 live
top-terms(1) delete, then the default delete of p:(a b)0 live1 live
doc-values delete; add doc 2; postings delete (matches nothing)doc 2 live0 live
controls: doc-values delete alone; a commit between the two deletes; an unequal second queryas expectedas 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 can IndexWriter itself when buffered deletes use too much heap (DocumentsWriter.java:153-166). A commit between the calls avoids it.
  • What it takes. Two TermInSetQuery objects with the same field and terms whose rewrite methods select different documents. With the documented pairing (postings TermInSetQuery next to newSlowSetQuery) 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 Query objects 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: a BooleanQuery with a FILTER clause of either variant compares equal. The monitor module's QueryDecomposer keeps the un-rewritten clauses of a registered query in sets (QueryDecomposer.java:77-102): a BooleanQuery with both variants as SHOULD clauses decomposes into 1 part, not 2, and with Presearcher.NO_FILTERING a 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: BooleanQuery de-duplication, LRUQueryCache and IndexOrDocValuesQuery compare rewritten queries, which differ in the executed cases (impact probes on main). MultiFacetQuery inherits 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.

Minimal repro

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.

TestTermInSetQueryDeleteByDocValuesSetQuery.java (one test class)46 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.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());
      }
    }
  }
}
Command
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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java logic)
Command (environment)
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)

Observed output

# 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)

Why it looks unintended

  • deleteDocuments(Query...) promises to delete "the document(s) matching any of the provided queries"; the doc-values query matches, yet the document stays.
  • MultiTermQuery.equals and hashCode include the rewrite method, and every sibling subclass's equals calls the parent's.
  • The terms-only equals predates 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 what equals should return.
  • The equality contract. Query#equals is "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/hashCode include the rewrite method (MultiTermQuery.java:336-352), and every other concrete subclass outside sandbox calls super.equals, except MultiFacetQuery, which inherits TermInSetQuery's override (the core siblings also call super.hashCode; join TermsQuery does not). For BlendedTermQuery, a test requires two queries that differ only in their rewrite method to be unequal (TestBlendedTermQuery.java:39-49).
  • History. The terms-only equals dates from LUCENE-7624 (2017), when TermInSetQuery extends Query had no rewrite method. #12156 (9.6.0) made it a MultiTermQuery with a public rewrite-method constructor and changed no line of equals/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 of hashCode only, 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's equals, 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.

Suggested fix

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.

Delegate to MultiTermQuery#equals and hashCode (recommended, validated)

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 of KeywordField.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.rewrite compares 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 custom RewriteMethod without equals makes queries built with different instances unequal (cache misses only), as for every other MultiTermQuery today.
  • 9.x. There MultiTermQuery#hashCode deliberately leaves out the then-mutable rewrite method while equals keeps 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 nightly TestTermInSetQuery.testDuel and TestIndexOrDocValuesQuery with -Ptests.nightly=true: 20 tests, 0 failures.
  • :lucene:core:test over org.apache.lucene.search.* and org.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.

Compare the rewrite method in equalsTo and hashCode (validated, equivalent)

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.

Key the delete buffer by identity instead (not recommended, not built)

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.

Downstream note

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.

How it was found

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 Query classes in the main jars and listed, for each class, the fields that other methods, such as rewrite(), read but equals/hashCode do not. MultiTermQuery.rewriteMethod was read by rewrite() but not by TermInSetQuery.equals/hashCode.
  • The unit's bounded-exhaustive equality-congruence harness ran small queries on small indexes and found equal TermInSetQuery pairs 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.deleteDocuments consequence, the follow-up re-ran it with the default configuration, and the repro was minimized to one test.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, JIRA, web2026-10-26133 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 fullno report; related #12312, #7571 (LUCENE-6513), #12156, #12175, #14425 / #14435, #11467 (LUCENE-10431), #8861 (LUCENE-7810)
GitHub (delete-by-query consequence)2026-10-261 REST and 3 GraphQL searchesno 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 mainonly known unrelated items (#16051, #14349, #16394, #12135, #9771) or no hits; last commit on the file 54283150a81 (2026-06-14, build-only)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal 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 affectedminimal repro fails, same message
9.6.0 firstthe 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 affecteddelete-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 affectedTermInSetQuery extends Query and has no rewrite method; the doc-values set query is a different class
with the recommended fix (645d4fe14a20) not affectedrepro passes; core search and document tests, monitor, facet and join pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidateTop-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), proceduralCould 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, mediumRated 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, minorOwn 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 realAdded 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, highRe-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 executeddeleteDocuments 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 verifiedThe 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 validatedRepro 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 correctedAn 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.

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

Finding record

LFV-321, snapshot of 2026-11-06

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 main ea57360912ed, a refresh re-ran the repros on a9779ef3c193; the files involved are identical in all three.

Lucene and upstream sources

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-352
  • lucene/core/src/java/org/apache/lucene/document/SortedSetDocValuesField.java:120-132; document/SortedDocValuesField.java:126-128; document/KeywordField.java:174-179
  • lucene/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-420
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:299-362; search/LRUQueryCache.java:834; search/IndexOrDocValuesQuery.java:131
  • lucene/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-352
  • lucene/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)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's output.

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.