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.
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.- Index three documents with a
colorfacet: red, blue and green.DrillDownQuery q = new DrillDownQuery(config)withq.add("color", "red")matches 1 document. - Copy it,
c = q.clone(), and OR blue into the copy:c.add("color", "blue"). You expectcto match red or blue (2) andqto keep matching red only (1). - You get 2 for both. The clone has its own list of dimensions, but the
BooleanQuery.Builderthat holds thecolorvalues is the same object in both, andadd()on an existing dimension appends blue to it. - If
qwas searched before, it keeps a cached red-only query and still says 1, yetq.equals(c)is true while they match 1 and 2 documents. Onceqadds green itself, it matches 3 instead of 2. - 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.
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.
- Drill-down:
DrillDownQuerynarrows 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.Builderper 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#clonesays a copy "should be independent of this object", and upstreamTestDrillDownQuery#testClone(:245-255) asserts that a change to the clone does not show up in the source.
clone()calls a package-private constructor that doesthis.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).add(dim, ...)on a dimension the copy already has runsdimQueries.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.- 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. equals()andhashCode()compare the builder list (:144-156);BooleanQuery.Builderdoes not overrideequals, so this is identity: a clone stays equal to its source while they match different documents.
Index: three documents, color = red, blue, green (SortedSetDocValuesFacetField).
| what the code does | copy counted | expected | actual |
|---|---|---|---|
q.add(color, red); c = q.clone(); c.add(color, blue) | q | 1 | 2 |
q.add(color, red); s = q.clone(); q.add(color, blue) | s | 1 | 2 |
search q first (1); c = q.clone(); c.add(color, blue) | q | 1 | 1 (cached) |
... then q.equals(c) (they match 1 and 2 documents) | false | true | |
... then q.add(color, green) | q | 2 (red or green) | 3 |
c = q.clone(); c.add(shape, square), a new dimension | q | 1 | 1 (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 thenadd()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,
IndexWriterConfigand 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 explicitclone()of an@lucene.experimentalclass. Lucene never callsclone()(none of the 11 main-source files that referenceDrillDownQuerydoes), DrillSideways shares the builders internally but never adds to them, and a read-only code search found noDrillDownQueryuse 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 addsstatus = draftto its clone, and every later clone of the template returns the draft document too, also throughDrillSideways.search, whether or not the template was searched first. Each suchadd()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 withIndexSearcher.TooManyClauses, thrown byadd(), until the template is rebuilt; a plain clone of the template still works. equals()side effect. An application-level result cache keyed on theDrillDownQuerycould serve one copy's results for the other, since they compare equal. Lucene's ownLRUQueryCacheis not affected: it keys on the rewrittenBooleanQuery(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).
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.
/*
* 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));
}
}
}
}
# 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-eaand-da).
More files, environment, full output41 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)clone()came withtestClone, 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 withclone()in LUCENE-4750 (4.2.0):q.add("a"),clone.add("b"), thenassertFalse("query wasn't cloned: ...", ...). It adds a new dimension, the one isolated case, so it passes; it is the only upstream test that callsDrillDownQuery#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, thatclone()should eventually be documented to "return an independant instance. So there are two options: either deep cloning or shallow cloning and be immutable".DrillDownQueryis 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.
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 alikeTrade-offs
clone()costs O(total clauses) instead of O(dimensions).equals()/hashCode()now build dirty dimensions first throughgetDrillDownQueries(), which writes the query's own cache (builtDimQueries,dirtyDimQueryIndex): they are no longer read-only and race on that cache when run concurrently withadd()or each other, asrewriteandtoStringalready 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
drillDownDimskeeps 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 afteradd()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 throwsTooManyClauses(the stock reference copy never does). Only clauses are copied, notminimumNumberShouldMatch, whichDrillDownQuerynever 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) andTestDrillSideways(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 andTooManyClausescases) pass.
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 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.
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.
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.
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.
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
DrillDownQueryandDrillSidewaysfor a bounded-exhaustive harness and read both classes. It noticed that the constructorclone()uses copies the builder references whileadd()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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | two 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 issues | no report; the related items above |
| Open PRs | 2026-10-26 | the changed files of all 410 open apache/lucene PRs | none touches DrillDownQuery.java; #16707 and #15221 touch DrillSideways.java for other reasons |
| JIRA archive and mailing lists | 2026-10-26 | all 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 full | no report |
| Downstream repositories and web | 2026-10-26 | apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch; 9 web queries | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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 | affected | this doc's test: 4 of 4 fail, the same messages |
| 9.0.0 | cache appears | the 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 sweep | API-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 only | add(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 affected | old API: adding the same dimension again throws or adds a clause to that copy only; clones stay independent |
| main + the fix | not affected | every repro passes; the whole lucene/facet suite passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-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 | real | Own 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 | real | The 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, keep | Fresh 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 validated | New 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 407 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,378 words
- 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 mainea57360912ed, 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 wholelucene/facetsuite (41 suites, 312 tests) with assertions on and off, and:lucene:facet:check -x test.
- 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-211lucene/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-107lucene/facet/src/test/org/apache/lucene/facet/TestDrillDownQuery.java:245-255java.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
- TestDrillDownQueryCloneIsolation.java (JUnit test):
repro/LFV-367/src/org/apache/lucene/facet/TestDrillDownQueryCloneIsolation.java(55 lines)
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.
assets/LFV-367/bug.png(407 KB)
