All findings · Lucene finding · LFV-367 · drilldownquery-clone-shares-dim-builders

DrillDownQuery.clone() shares each dimension's builder, so adding a value to one copy changes the other

Clone a DrillDownQuery and add a value to a dimension both copies already constrain, and the other copy matches that value too: q restricts color = red, q.clone().add("color", "blue"), and q now matches 2 documents instead of 1. Every release from 4.7.0 to 10.5.2 and main does this.

Summary

Priority
Low, rank 334Only callers of the rarely used DrillDownQuery.clone() (no in-tree or major downstream caller) who extend an already constrained dimension are hit.
Impact
silent wrong results
Since
4.7.0 (LUCENE-5339, 2014)introduced 2013-11-17 · over 12 years
Affected
4.7.0 to 10.5.2 and main
Status
documented
Found by
code reading, confirmed by JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

DrillDownQuery ANDs one filter per facet dimension onto a base query; calling add() again for the same dimension ORs another value into it. clone() should give an independent copy: upstream testClone fails with "query wasn't cloned" when a change to the clone shows up in the source.

Summary card. Title: DrillDownQuery.clone() shares its per-dimension builders, so adding a value to one copy changes the other. Left: the original and the clone, each with a dimQueries list whose entry points at one shared BooleanQuery.Builder holding color:red SHOULD and color:blue SHOULD; clone.add("color", blue) lands in the original's dimension too. Right: DrillDownQuery.java:68, this.dimQueries.addAll(dimQueries), and :135, dimQueries.get(index).add(subQuery, Occur.SHOULD); fix: clone() gives each dimension its own builder. Example, executed: doc 0 color=red, doc 1 color=blue; expected count(original) = 1, actual 2. Notes: since 9.0 a copy searched first hides the leak, then matches 3 documents where 2 are right; the copies stay equals() with the same hashCode(); Lucene never calls clone(); adding a new dimension to a clone is correct.
The original and its clone each have their own list of dimensions, but both lists point at one BooleanQuery.Builder for color. clone.add("color", blue) appends to that builder, so the original matches 2 documents instead of 1. Executed on main with two documents, red and blue. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents with a color facet: red, blue and green. DrillDownQuery q = new DrillDownQuery(config) with q.add("color", "red") matches 1 document.
  2. Copy it, c = q.clone(), and OR blue into the copy: c.add("color", "blue"). You expect c to match red or blue (2) and q to keep matching red only (1).
  3. You get 2 for both. The clone has its own list of dimensions, but the BooleanQuery.Builder that holds the color values is the same object in both, and add() on an existing dimension appends blue to it.
  4. If q was searched before, it keeps a cached red-only query and still says 1, yet q.equals(c) is true while they match 1 and 2 documents. Once q adds green itself, it matches 3 instead of 2.
  5. No error is raised, and nothing depends on the index or codec: the shared state lives in the query objects. Adding a new dimension to a copy is isolated, and Lucene never calls clone() itself. The recommended fix gives the clone its own builders; the whole facet test suite passes with it.

Who is affected

Applications that call DrillDownQuery.clone() themselves and then add a value to a dimension the source already constrains, for example a template query cloned per request for multi-select facets. The other copy, and every later clone of the template, silently matches the extra value, on every release since 4.7.0.

Mechanism, terms and numbers637 words

DrillDownQuery keeps one BooleanQuery.Builder per facet dimension, and add() on a dimension that is already present ORs another value into that builder, as its javadoc documents. clone() hands the source's builders to the copy by reference (DrillDownQuery.java:68, this.dimQueries.addAll(dimQueries)), so add() on an existing dimension of either copy changes what the other copy matches: q restricts color = red, q.clone().add("color", "blue"), and q matches 2 documents instead of 1. Since 9.0 a copy that was already searched hides the leak until its own dimension changes (then 3 documents where 2 are right), and copies that match different documents compare equals() with the same hashCode(). Adding a new dimension to a copy is isolated, which is the only case upstream testClone covers. Lucene never calls clone() itself, so only applications that clone a DrillDownQuery and extend a dimension are hit, silently, on every release from 4.7.0 to 10.5.2 and main. Copying the builders in clone(), and comparing built dimension queries in equals(), fixes it; the whole lucene/facet test suite passes with that change.

Terms

  • Drill-down: DrillDownQuery narrows a base query (or all documents) to facet values. Each dimension becomes one FILTER clause; the values of one dimension are OR'd: "if you pass the same dimension more than once it is OR'd with the previous constraints on that dimension, and all dimensions are AND'd against each other and the base query" (DrillDownQuery.java:111-115).
  • Per-dimension builder: the query keeps one BooleanQuery.Builder per dimension (DrillDownQuery.java:56), adds each value to it as a SHOULD clause, and builds the dimension's query lazily, caching the result per copy until that dimension changes (:57-59, :204-211; the cache dates from 9.0).
  • clone() (:139-142) has no javadoc. Object#clone says a copy "should be independent of this object", and upstream TestDrillDownQuery#testClone (:245-255) asserts that a change to the clone does not show up in the source.

The bug

  1. clone() calls a package-private constructor that does this.dimQueries.addAll(dimQueries) (DrillDownQuery.java:68): a new list holding the same builder objects. The dimension map is copied (:69), and every dimension of the new copy is marked dirty (:71-74).
  2. add(dim, ...) on a dimension the copy already has runs dimQueries.get(index).add(subQuery, Occur.SHOULD) (:135): the clause goes into the shared builder. Only the receiving copy is marked dirty (:136). A new dimension gets a new builder in that copy's own list (:128-133), so new dimensions stay isolated.
  3. Any copy that builds a dirty dimension, including every fresh clone, reads the shared builder with the other copy's clause in it (:204-211). A copy that was already built keeps its cached query until its own dimension changes again, and then picks up everything in the builder.
  4. equals() and hashCode() compare the builder list (:144-156); BooleanQuery.Builder does not override equals, so this is identity: a clone stays equal to its source while they match different documents.

The numbers

Index: three documents, color = red, blue, green (SortedSetDocValuesFacetField).

what the code doescopy countedexpectedactual
q.add(color, red); c = q.clone(); c.add(color, blue)q12
q.add(color, red); s = q.clone(); q.add(color, blue)s12
search q first (1); c = q.clone(); c.add(color, blue)q11 (cached)
... then q.equals(c) (they match 1 and 2 documents)falsetrue
... then q.add(color, green)q2 (red or green)3
c = q.clone(); c.add(shape, square), a new dimensionq11 (right)

The first five rows were run on main with assertions on and off and on 10.5.2 and 9.12.3; the last is an independent reproducer's control on main. On 8.11.2 and older there is no cache: the searched copy changes at once (2, then 3).

Exposure and likelihood361 words
  • Who is exposed. Applications that call DrillDownQuery#clone() and then add() on either copy for a dimension the source already constrains, while the other copy is still used: a base or template query cloned per request or per facet value, where another value is OR'd into an already selected dimension. Adding a new dimension to a clone is not affected.
  • Default vs opt-in. Default codec, IndexWriterConfig and searcher; no index-size prerequisite (the runs used small indexes of 2 to 24 documents, and the shared state lives in the query objects). It needs an explicit clone() of an @lucene.experimental class. Lucene never calls clone() (none of the 11 main-source files that reference DrillDownQuery does), DrillSideways shares the builders internally but never adds to them, and a read-only code search found no DrillDownQuery use in Solr or Elasticsearch. No public report was found in the prior-report searches.
  • Silent and one-sided. No exception or log line. The extra matches stay on the affected dimension: the base query, the other dimensions and a DrillSideways filter are never widened, and no document is lost (a randomized check over more than 1,000 searches in 4 runs: stock main with assertions on and off, 10.5.2, and main with the fix).
  • Template pattern (executed on main and 10.5.2). A template restricting status = published, cloned per request: one request adds status = draft to its clone, and every later clone of the template returns the draft document too, also through DrillSideways.search, whether or not the template was searched first. Each such add() grows the template's builder by one clause, so with the default limit of 1,024 clauses the 1,024th request that adds a value and every later one fail with IndexSearcher.TooManyClauses, thrown by add(), until the template is rebuilt; a plain clone of the template still works.
  • equals() side effect. An application-level result cache keyed on the DrillDownQuery could serve one copy's results for the other, since they compare equal. Lucene's own LRUQueryCache is not affected: it keys on the rewritten BooleanQuery (executed).
  • Not assessed. Several threads cloning and adding to one shared template would also race on the builder's unsynchronized clause list (code reading only).

Minimal repro

The test shows that DrillDownQuery.clone() shares the per-dimension BooleanQuery.Builder with the original, so adding color=blue to the clone also widens the original.

TestDrillDownQueryCloneIsolation.java (JUnit test)55 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.facet;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDrillDownQueryCloneIsolation extends LuceneTestCase {

  public void testAddToCloneDoesNotChangeOriginal() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (String color : new String[] {"red", "blue"}) {
          Document doc = new Document();
          doc.add(new StringField("color", color, Field.Store.NO));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        DrillDownQuery original = new DrillDownQuery(new FacetsConfig());
        original.add("color", new TermQuery(new Term("color", "red")));
        DrillDownQuery clone = original.clone();
        clone.add("color", new TermQuery(new Term("color", "blue")));
        assertEquals(
            "original (color=red) after adding color=blue to its clone",
            1,
            new IndexSearcher(r).count(original));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-367/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

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

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.facet.TestDrillDownQueryCloneIsolation
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: the original (color=red) still matches 1 of the 2 documents. Actual: 2 (expected:<1> but was:<2>, with -ea and -da).

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

More files, environment, full output41 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: original (color=red) after adding color=blue to its clone expected:<1> but was:<2>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.facet.TestDrillDownQueryCloneIsolation.testAddToCloneDoesNotChangeOriginal(TestDrillDownQueryCloneIsolation.java:48)

Why it looks unintended

  • clone() came with testClone, which fails with "query wasn't cloned" when a change to the clone shows up in the source.
  • add() documents a repeated dimension as normal use: it "is OR'd with the previous constraints on that dimension".
  • Results depend on whether a copy was searched first, and copies matching 1 and 2 documents are equals().
The full argument283 words
  • Contract. add(String, String...) (DrillDownQuery.java:111-115): "if you pass the same dimension more than once it is OR'd with the previous constraints on that dimension", on the copy you call it on. Object#clone: "By convention, the object returned by this method should be independent of this object ... this means copying any mutable objects that comprise the internal 'deep structure'". The builders are such internal state: add() creates them; the caller never supplies them. Query#equals (Query.java:99-101): "Typically a query will be equal to another only if ... its document-filtering properties are identical".
  • Tests. TestDrillDownQuery#testClone (:245-255), added with clone() in LUCENE-4750 (4.2.0): q.add("a"), clone.add("b"), then assertFalse("query wasn't cloned: ...", ...). It adds a new dimension, the one isolated case, so it passes; it is the only upstream test that calls DrillDownQuery#clone.
  • History. The author of LUCENE-4750 wrote: "Also implemented clone() (there's a test showing what would happen if we don't)". Until 4.6.1 adding the same dimension again threw or added a clause to that copy only, so the shallow copy was harmless. LUCENE-5339 (4.7.0) made it OR the value in place into the shared per-dimension query; nothing in that change discusses clone(). LUCENE-6570 (5.3.0) kept the sharing when it moved to builders; in its review @jpountz wrote, about queries in general, that clone() should eventually be documented to "return an independant instance. So there are two options: either deep cloning or shallow cloning and be immutable". DrillDownQuery is neither.
  • Coherence. Since 9.0 (LUCENE-9956) whether a copy sees the other's value depends on whether it was searched first, and copies that match 1 and 2 documents are equals(). No design produces that on purpose.
The devil's-advocate check199 words

The strongest "intended" case: clone() has no javadoc; the class is @lucene.experimental; Java's List and Map clones are shallow, and in LUCENE-6369 ("Make queries more defensive and clone deeply", Won't Fix) a committer defended shallow query clones ("clone() did exactly what I wanted previously... a shallow clone. It's also more in line with Java cloning of things like List, Map"); DrillDownQuery is a known mutable query (#8134), so clone-then-mutate could be called a pattern to avoid.

Why it fails: a shallow List clone never lets clone.add(x) change the original, and here clone.add(dim, ...) does, because the shared objects are builders that add() created inside the query; LUCENE-6369 was about sub-queries the caller supplied. The author of clone() added it so that a copy is independent, with a test that says so. add() documents OR-ing into an existing dimension, not into other copies. @lucene.experimental is about API stability, not wrong results, and #8134 is about mutating a query after it was cached; it never mentions clones. Verdict: a defect (adjudication: confirmed, high; the independent reproduction and the intended-behaviour check real; the other-model-family intended-lens review rated the intended case moderate and rejected it). Low severity: the call pattern is rare.

Suggested fix

Recommended Copy the builders in clone(), compare built dimension queries in equals()/hashCode()

Give the clone its own builder per dimension (a new BooleanQuery.Builder with the clauses of each one), and compare the built dimension queries in equals()/hashCode() instead of builder identity (+20/-4 in DrillDownQuery.java). The repro, TestDrillDownQuery, TestDrillSideways and the whole lucene/facet suite (312 tests) pass, assertions on and off.

All options and trade-offs5 options · 932 words

Each copy then owns its builders, so add() reaches only the copy it is called on (new dimensions were already isolated), and equality compares the built dimension queries by content, per dimension position, as DrillSidewaysQuery does for its built drillDownQueries array (not full match-equivalence: the same dimensions added in a different order still compare unequal). The clone() half alone fixes every wrong match, including equal copies that match different documents, and is a credible minimal fix; the equals()/hashCode() half is a visible behaviour change (identical queries become equal) and can be proposed separately. Making DrillDownQuery immutable (#8134) would also remove the bug. Workaround until a fix ships: build a fresh DrillDownQuery per variant instead of clone() followed by add() on an existing dimension, and keep access restrictions in the base query or the DrillSideways filter.

Copy the builders in clone(), compare built dimension queries in equals()/hashCode() (validated by the checks) (recommended)

clone() builds each dimension once and copies its clauses into a new BooleanQuery.Builder; hashCode() and equalsTo() compare baseQuery, drillDownDims and getDrillDownQueries() instead of the builder list. Two imports, and the stale constructor javadoc "Used by clone() and DrillSideways" (only clone() calls it) is corrected. +20/-4 in DrillDownQuery.java.

  public DrillDownQuery clone() {
    // add() on an existing dimension appends to that dimension's builder in place, so the copy
    // needs its own builders; sharing them would leak the new clause into the other copy.
    List<BooleanQuery.Builder> copies = new ArrayList<>(dimQueries.size());
    for (BooleanQuery.Builder dimQuery : dimQueries) {
      BooleanQuery.Builder copy = new BooleanQuery.Builder();
      for (BooleanClause clause : dimQuery.build()) {
        copy.add(clause);
      }
      copies.add(copy);
    }
    return new DrillDownQuery(config, baseQuery, copies, drillDownDims);
  }
  // equalsTo: Objects.equals(baseQuery, ...) && drillDownDims.equals(...)
  //   && Arrays.equals(getDrillDownQueries(), other.getDrillDownQueries()); hashCode alike

Trade-offs

  • clone() costs O(total clauses) instead of O(dimensions).
  • equals()/hashCode() now build dirty dimensions first through getDrillDownQueries(), which writes the query's own cache (builtDimQueries, dirtyDimQueryIndex): they are no longer read-only and race on that cache when run concurrently with add() or each other, as rewrite and toString already do (no thread-safety guarantee existed).
  • Content equality is a behaviour change: a fresh clone equals its source, the two stop being equal once one changes, and two separately built identical queries become equal (they are unequal today; #11098 noted that in passing as a benign cache miss). Including drillDownDims keeps queries with different dimension names unequal. It is not full match-equivalence: the comparison is per dimension position, so the same dimensions added in a different order, or the same subqueries under different dimension names, compare unequal.
  • hashCode() now also changes after add() on an existing dimension (today only a new dimension changes it), so a query used as a hash key and then extended is no longer found under that key.
  • Also in the clone-only option: the copy replays each clause through BooleanQuery.Builder.add, which checks the current clause limit, so if the application lowered the limit below a dimension's clause count after building the query, clone() now throws TooManyClauses (the stock reference copy never does). Only clauses are copied, not minimumNumberShouldMatch, which DrillDownQuery never sets. (Code reading.)

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: the new test fails 4 of 4 on stock (assertions on and off) and passes with the fix; with the fix the new test, an optional 2-test equality class (fails on stock), TestDrillDownQuery (13) and TestDrillSideways (14): 33 tests, 0 failures, assertions on and off; the whole :lucene:facet:test, 41 suites and 312 tests, 0 failures, assertions on (7 skipped) and off (8 skipped); :lucene:facet:check -x test (google-java-format, ecj lint, forbidden APIs) passes.
  • Standalone runner with the fix shadowed: the new test (-ea, -da), the finder's test (2 of 2), the independent reproducer's test (7 of 7) and the security probe (7 of 7, including its template, DrillSideways and TooManyClauses cases) pass.

Copy the builders in clone() only (validated by the checks)

The same clone() and javadoc change, with equals()/hashCode() untouched (+13/-2).

Trade-offs

Fixes every wrong match, and copies that differ are no longer equal. Keeps identity equality: a fresh, unchanged clone is no longer equal to its source (it is today), and separately built identical queries stay unequal; only a cache keyed on the query notices. Identity equality on its own is defensible; equal copies that match different documents are not, and this option already removes those. It shares the clone-replay caveats of the first option.

What the checks validated

Gradle, assertions on: the new test, TestDrillDownQuery and TestDrillSideways, 31 tests, 0 failures; the new test passes through the standalone runner. The optional equality class fails with it, as expected.

Copy where the sharing is created

Copy the builders in the 4-argument constructor (only clone() calls it), and optionally in the DrillSideways constructor (DrillDownQuery.java:78-91, addAll at :84).

Trade-offs

Same effect as the clone() change; the DrillSideways copy only hardens an internal query that is never mutated, at the cost of copying clauses on every DrillSideways.search(query, filter, ...). Not run.

Make DrillDownQuery immutable (#8134)

An immutable query plus a separate builder, as #8134 asks for the remaining mutable queries.

Trade-offs

Removes this bug and the caching concern of #8134 together, but changes the API of an @lucene.experimental class; a larger discussion. Not run.

Workaround: build a fresh DrillDownQuery per variant

Instead of clone() followed by add() on an existing dimension, build each variant from its list of values (adding a new dimension to a clone is safe). Keep access restrictions in the base query or the DrillSideways filter.

Trade-offs

Application-side only; not a fix.

Downstream note

Applications that keep a template query restricting a dimension (say, status published) and clone it per request let one request's added value widen every later request on that dimension, never beyond it; from the 1,024th add on, each request that adds a value fails with TooManyClauses (plain clones still work). Keep access restrictions in the base query or DrillSideways filter.

How it was found

While modelling drill-down semantics for a bounded-exhaustive test harness of the facet module, an AI finder read DrillDownQuery and noticed that clone() copies the builder references. The harness did not produce the signal; a direct public-API JUnit test confirmed it, and independent AI tools then reproduced, reviewed and judged it.

The method in full111 words

Method: code reading (the hunt's "non-formal" tier), a hunt unit (facets: taxonomy, SSDV and DrillSideways), 2026-10-26, stock main jars.

  • The finder was modelling the drill-down semantics of DrillDownQuery and DrillSideways for a bounded-exhaustive harness and read both classes. It noticed that the constructor clone() uses copies the builder references while add() mutates an existing dimension's builder in place.
  • It wrote a 2-test public-API JUnit test (TestDrillDownQueryCloneShares), which failed on main with assertions on and off and on releases back to 7.7.3. The harness itself did not find it.
  • Then independent AI tools reproduced, reviewed and judged it (see the checks), and the history step found the introduction in 4.7.0.

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-26): the scripted prior-report search's 91 queries over GitHub, commits, downstream projects, four mailing lists, JIRA and the web, every issue, PR, JIRA entry and list thread that mentions DrillDownQuery, and all 410 open PRs. Related: #8134, open since 2016, lists DrillDownQuery among the remaining mutable queries.

Searches4 searches

No issue, PR, JIRA entry, list thread or web page describes DrillDownQuery#clone() sharing its per-dimension state, or the wrong matches that follow. DrillDownQuery.java is unchanged on upstream main since #16056 (2026-05-14), and none of the 410 open PRs examined on 2026-10-26 touches it.

Related, not this bug: #8134 (LUCENE-7078, open) lists DrillDownQuery among the "remaining mutable Queries" because mutating a query after it was cached corrupts the cache; it never mentions clone() or shared state, and its fix (an immutable query) differs from the minimal one, so the hunt judged it a related issue, not a partial overlap, and kept the novelty new. #5815 (LUCENE-4750) added clone() and testClone; #7628 (LUCENE-6570) moved the sharing to builders; #7429 (LUCENE-6369, Won't Fix) is the generic shallow-clone hazard; #11098 (LUCENE-10060) mentions DrillDownQuery's identity-like equals in passing; #5998 (LUCENE-4934) fixed equals/hashCode in 2013, before the builder list; #138 / #10995 (LUCENE-9956) added the cache. The Lucene.NET 4.8 port likely has the same aliasing (read, not run); no report there. Gaps: keyword search only, GitHub search was rate-limited, Stack Overflow pages could not be fetched, private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26two scripted prior-report search runs (51 and 40 queries, with the verifiers' 37 and 24 earlier) and manual searches: every issue and PR mentioning DrillDownQuery (32), phrasings such as independent, aliasing, copy, shared builder; commit search; all 36 open facet-module issuesno report; the related items above
Open PRs2026-10-26the changed files of all 410 open apache/lucene PRsnone touches DrillDownQuery.java; #16707 and #15221 touch DrillSideways.java for other reasons
JIRA archive and mailing lists2026-10-26all 24 LUCENE issues mentioning DrillDownQuery (10 read in full); 16 direct list queries; every java-user@ (16) and dev@ (3) thread mentioning DrillDownQuery, 7 read in fullno report
Downstream repositories and web2026-10-26apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch; 9 web queriesno report

History

Introduced in 4.7.0 by LUCENE-5339 (2013-11, landed 2014-01), which made adding to an existing dimension OR the value in place while clone() stayed shallow; 5.3.0 moved the sharing to builders, and 9.0.0 added the cache that hides it in a searched copy. Every release from 4.7.0 to 10.5.2 and main fails; 4.6.1 does not.

Versions tested7 rows

Introduced by LUCENE-5339 "Simplify the facet module APIs" (#6403), commit 45a73c005e6 "LUCENE-5339: simplify DrillDownQuery" (branch commit 2013-11-17), landed on trunk 9d0b60388da and branch_4x 7477fde1cb0 on 2014-01-04 (svn era, no PR; CHANGES.txt 4.7.0). It added an OR-merge into an existing dimension that edits the per-dimension query in place, while clone() (LUCENE-4750, 4.2.0; trunk 456b429d106, branch_4x ec68f411afb) stayed shallow. Later changes: LUCENE-5418 (4.7.0) made add(dim, Query) throw on an existing dimension until 5.3.0; LUCENE-6570 (5.3.0, 4aafacfbef4 / 94716da23a5) moved the shared state to BooleanQuery.Builder objects, which is what git blame shows today; LUCENE-9956 (9.0.0, 27b009c5d04, #138; its CHANGES entry mentions only the exposed getters) added the per-copy cache that makes the symptom depend on whether a copy was searched. DrillDownQuery.java last changed in #16056 (2026-05-14).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 4 of 4 fail (gradle and the standalone runner, assertions on and off): 2 instead of 1 twice, 3 instead of 2, equal copies
10.5.2, 9.12.3 affectedthis doc's test: 4 of 4 fail, the same messages
9.0.0 cache appearsthe finder's test: 2 of 2 fail; a searched copy hides the leak (1), then 3 instead of 2
5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.2 release sweepAPI-adapted programs and the finder's test fail; the add(dim, Query) overload leaks too (executed on 5.3.0, 7.7.3 and 8.11.2); on 8.11.2 a searched copy changes at once (2, then 3)
4.7.0, 4.10.4, 5.0.0, 5.2.1 path overload onlyadd(dim, path...) leaks (original 2, snapshot 2); add(dim, Query) on an existing dimension throws there (on 5.0.0 only the clone-to-original case was run; the rest is code reading)
4.6.1 not affectedold API: adding the same dimension again throws or adds a clause to that copy only; clones stay independent
main + the fix not affectedevery repro passes; the whole lucene/facet suite passes

Independent checks

An independent reproducer and a devil's advocate found it real with their own executed tests; an adjudication confirmed it by re-running the reproducer's test with a fresh seed. A read-only review by another model family agreed; a refute-lens review that could not run code gave a procedural "not real". An audit and this doc's gradle runs reproduced it on main.

Checks and verdicts408 words

Every check on main ran on the stock jars with assertions on and off (the fix checks with a shadowed class); the 10.5.2 and 9.12.3 runs used both, the older-release runs assertions on only; the reviews by the other model family ran read-only (they could not execute code), and a separate run re-ran the finder's test each time, which failed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3, and as an API-adapted copy on 9.0.0, 8.11.2 and 7.7.3.
cross-model review, refute lens not real (low, procedural)An independent reviewer from another model family (OpenAI GPT-5.6), read-only: could not execute, so "not reproduced" by construction; its substantive evidence supports real; a separate re-run failed as claimed.
Independent reproducer realOwn 7-test JUnit from another package: 4 fail on main (two seeds, -ea, -da), 10.5.2 and 9.12.3, 3 controls pass; old-API programs fail on 4.7.0-5.3.0; found the cache case; a clone() that copies the builders passes 7 of 7, and upstream TestDrillDownQuery passes 13 of 13 with it.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, read-only: rated the intended argument (no javadoc, shallow-clone convention, experimental) moderate and rejected it; root cause agrees, inputs in contract.
Devil's advocate realThe shared builders are created by add(), not supplied by the caller; clone() was added to give an independent copy. Fails on 4.7.0-6.6.6; equal copies match 2 and 1 documents.
adjudication confirmed (high)Re-ran the independent reproduction's test with a fresh seed (4 of 7 fail, -ea and -da; 0 with the shadow fix); corrected the introduction point to LUCENE-5339 (4.7.0).
security assessment none (re-evaluated)Only the application's own clone()+add() code reaches it; the over-match never leaves the dimension (randomized check, 0 violations); out of scope under SECURITY.md.
registry re-runs and audit reproduced, keepFresh seeds on main: the finder's test fails with -ea and -da; the independent reproduction's test fails 4 of 7 and passes with the shadow fix; a fourth-round audit re-ran it again and kept the row.
this doc's validation reproduced, fix validatedNew test: 4 of 4 fail on main (gradle and standalone, -ea and -da), 10.5.2 and 9.12.3; with the fix it passes, and so do TestDrillDownQuery, TestDrillSideways and the whole lucene/facet suite (312 tests), assertions on and off.

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

Finding record

LFV-367, snapshot of 2026-11-06

Registry title
DrillDownQuery.clone() shares the per-dimension BooleanQuery.Builder objects with the original, so adding a value to a dimension both copies already constrain changes what the other copy matches (released since 4.7.0)
Area
facet / DrillDownQuery (clone() and the per-dimension BooleanQuery.Builder state)
Priority
Low, rank 334: Only callers of the rarely used DrillDownQuery.clone() (no in-tree or major downstream caller) who extend an already constrained dimension are hit.
Impact class
silent wrong results
Impact note
Applications that call DrillDownQuery#clone() themselves (Lucene has no internal caller, and a read-only code search found none in Solr, Elasticsearch or OpenSearch) and then add a value to a dimension the source already constrains, through add(dim, path...) or add(dim, Query), silently get extra matches on that dimension in the other copy, and in every later clone of a long-lived template, with the default codec, IndexWriterConfig and searcher, at any scale, in every release from 4.7.0 to 10.5.2 and on main; adding a NEW dimension to a clone (the upstream testClone case) is correct, and DrillSideways' own internal sharing never mutates the builders.
Summary
You build a DrillDownQuery that restricts color=red, call clone(), and add color=blue to the clone (or to the original), expecting the copy you did not touch to keep matching only red, as clone() and the upstream testClone imply. Instead both copies match red OR blue: the clone shares each dimension's internal BooleanQuery.Builder with its source, and add() on an existing dimension appends a SHOULD clause to that shared builder in place. Since 9.0 a copy that was already searched keeps a cached snapshot and shows the leaked value only after its own dimension changes again (then 3 hits where 2 are correct), and the two copies stay equals() with the same hashCode while they match different documents.
Introduced
45a73c005e6bc6b1c24e8636cfb5433c6a726a9d, LUCENE-5339 'simplify DrillDownQuery' (GitHub mikemccand, svn r1542804 on branch lucene5339; migrated issue #6403 'Simplify the facet module APIs'), landed as trunk 9d0b60388da8 and branch_4x 7477fde1cb06 (svn r1555338 / r1555342, 'LUCENE-5371, LUCENE-5339: ... simplify facet APIs'); SVN era, no PR [read: it added a merge() that ORs a new path into an existing dimension by calling add() in place on that dimension's inner BooleanQuery, while clone() stayed a shallow query.clone() that shares the inner query; before it, add() on an existing dimension threw IllegalArgumentException] [exec: release boundary 4.6.1 not affected, 4.7.0 affected]. Not LUCENE-6570, which git blame shows today: 4aafacfbef4 (trunk) / 94716da23a5 (branch_5x, released 5.3.0) only carried the existing sharing over to List<BooleanQuery.Builder> (this.dimQueries.addAll) (2013-11-17 (branch commit); 2014-01-04 (trunk and branch_4x); first released 2014-02 in 4.7.0)
First affected version
4.7.0 [exec] (last unaffected 4.6.1 [exec with the pre-4.7 API, since the 4.7 API did not exist yet: add() on an existing dimension threw IllegalArgumentException, so a clone stayed independent for every legal operation]; DrillDownQuery and clone() exist since 4.2.0, LUCENE-4750)
Affected versions
every release from 4.7.0 to 10.5.2 and main ea57360912ed [read: all 110 release tags 4.7.0..10.5.2 in the history clone contain the sharing pattern; 4.7.0-5.2.1 merge() into the shared inner BooleanQuery, 5.3.0+ dimQueries.addAll of shared builders]; executed with repro.sh (JDK 25, SHA-1-verified release jars, API-adapted sources for old lines): FAIL on 4.7.0, 4.10.4, 5.0.0, 5.2.1, 5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.2, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da); PASS on 4.6.1 (old-API equivalent). In 4.7.0-5.2.1 only add(String, String...) triggers it (add(String, Query) on an existing dimension throws there, LUCENE-5418); from 5.3.0 both overloads do. Since 9.0.0 (LUCENE-9956, per-copy cache of built dimension queries) the symptom depends on whether a copy was already searched [exec on 9.0.0, 10.5.2 and main; on 8.11.2 the leak shows at once]. Upstream main DrillDownQuery.java is unchanged since #16056 (2026-05-14), the tested version (prior-report record)
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new: scripted prior-report search a (51 queries) and run b (40 queries) over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web, plus the independent reproduction's (37) and the intended-behaviour check's (24) earlier runs; manual GitHub searches including apache/lucenenet, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; every LUCENE JIRA issue mentioning DrillDownQuery (24 titles, 10 read in full); every java-user@ and dev@ thread mentioning DrillDownQuery (19 listed, 7 read in full); the changed files of all 410 open apache/lucene PRs, none touching DrillDownQuery.java; 22 hits classified, all same_bug false). Related, not this bug: #8134 (LUCENE-7078, open since 2016) lists DrillDownQuery among the remaining mutable queries; its concern is a query mutated after it was cached, and it never mentions clone() or shared state; making DrillDownQuery immutable would also remove this bug; #7628 (LUCENE-6570, 5.3.0) carried the sharing over to builders, and jpountz wrote there that clone() should return an independent instance; #5815 (LUCENE-4750) added clone() and testClone; #7429 (LUCENE-6369, Won't Fix) is the generic shallow-clone hazard of mutable queries; #11098 (LUCENE-10060) remarks in passing that DrillDownQuery 'doesn't implement equals, so the cache always misses' (the identity-equals side effect, as a benign aside). Not fixed upstream: DrillDownQuery.java unchanged on upstream main since #16056 (2026-05-14). Gaps: private reports to security@lucene.apache.org cannot be searched; GitHub search is phrase-like and was rate-limited; list search is keyword-based; Q&A pages could not be fetched (titles only); searches cannot prove absence
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (this doc's test). Earlier checks ran on main ea57360912ed, whose facet module is identical, and with API-adapted programs on releases from 4.6.1 (not affected) to 10.5.2. The fix was validated in the scratch worktree: the new test, TestDrillDownQuery, TestDrillSideways, the whole lucene/facet suite (41 suites, 312 tests) with assertions on and off, and :lucene:facet:check -x test.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:45 (@lucene.experimental), 56-59 (dimQueries, builtDimQueries, dirtyDimQueryIndex), 61-75 (constructor used by clone(): this.dimQueries.addAll(dimQueries) at :68, every dimension marked dirty), 77-91 (DrillSideways constructor, also addAll), 111-119 (add(dim, path...) javadoc: 'if you pass the same dimension more than once it is OR'd with the previous constraints on that dimension'), 125-137 (add(dim, Query): dimQueries.get(index).add(subQuery, Occur.SHOULD) at :135, only this copy marked dirty at :136), 139-142 (clone(), no javadoc), 144-156 (hashCode/equals over the builder list, identity), 177-187 (each dimension a FILTER clause next to the MUST base query), 204-211 (getDrillDownQueries rebuilds dirty dimensions from the shared builder)
  • lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:192 (new DrillDownQuery(config, filter, query): the only internal sharing site, never mutated afterwards)
  • lucene/facet/src/test/org/apache/lucene/facet/TestDrillDownQuery.java:245-255 (testClone: 'query wasn't cloned', adds a NEW dimension only)
  • https://github.com/apache/lucene/issues/8134 (read 2026-10-26 by the registration check: body lists org.apache.lucene.facet.DrillDownQuery as a remaining mutable query; concern is cache corruption after mutation; one comment, a bulk version move; no mention of clone)
The finding in 30 seconds
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:45, :56-59, :61-75, :111-119, :125-137, :139-142, :144-156, :158-187, :204-211
  • lucene/facet/src/test/org/apache/lucene/facet/TestDrillDownQuery.java:245-255 (testClone)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:42-122 (Builder: no equals/hashCode override)
  • java.lang.Object#clone javadoc (JDK 25 source, java.base/java/lang/Object.java:196-201)
Who is affected
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:45 (@lucene.experimental), :61-91 (both sharing constructors); lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:192 (the only internal sharing site, never mutated)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:82-84 (TooManyClauses); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:80 (maxClauseCount = 1024)
Why it looks unintended
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:111-115, :139-142, :144-156; lucene/core/src/java/org/apache/lucene/search/Query.java:95-107
  • lucene/facet/src/test/org/apache/lucene/facet/TestDrillDownQuery.java:245-255
  • java.lang.Object#clone javadoc (JDK 25 source, java.base/java/lang/Object.java:196-201)
  • lucene/CHANGES.txt:14274 (LUCENE-4750, 4.2.0), :12913 (LUCENE-5339, 4.7.0), :10424 (LUCENE-6570, 5.3.0), :4741 (LUCENE-9956, 9.0.0)
Suggested fix
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:61-91, :139-156, :204-211; lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:82-84 (the clause-limit check in Builder.add); lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysQuery.java:221-239 (its built drillDownQueries array compared by content; its collector managers by identity)
  • https://github.com/apache/lucene/issues/8134 (make remaining mutable queries immutable); https://github.com/apache/lucene/issues/11098 (identity-like equals noted in passing)
Prior reports
  • https://github.com/apache/lucene/issues/8134 (body re-read 2026-10-26)
History
  • lucene/CHANGES.txt:14274, :12913, :10424, :4741

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

Repro files

About the diagrams

A light summary card, made in parallel with this doc from an executed per-step trace on main a9779ef3c193 (with a fix as control); the PNG is the summary card.

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.