All findings · Lucene finding · LFV-358 · drillsideways-advance-dim1-skips-twophase

Sequential DrillSideways skips the exact check of the second-cheapest drill-down dimension on segments without deletions

On a segment without deletions, sequential DrillSideways can skip the exact matches() check of its second-cheapest drill-down (in one of three cost-based strategies), so near-misses of a range, distance or phrase drill-down become hits and facet counts. Brand = acme plus price 0-10 gives 2 hits, not 1; since 5.3.0.

Summary

Priority
Medium, rank 35Default sequential DrillSideways has returned wrong drill-down hits and facet counts on ordinary deletion-free segments since 5.3, but only when a two-phase dimension is the second-cheapest.
Impact
silent wrong results
Since
5.3.0 (LUCENE-6648, 2015)introduced 2015-07-01 · over 11 years
Affected
5.3.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while building a bounded-exhaustive check against a reference

Upstream status: Fix in progress, by reugn (open PR #16981; an independent fix, later than our listing). Open PR #16981 (created 2026-11-04) fixes the precedence bug as a side part of DrillSideways timeout and intra-segment concurrency support; its own brute-force TestDrillSidewaysScorer found it. It does not cite this list. Novelty stays new. Source

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. A fix for it is open upstream (see Upstream status). Snapshot of 2026-11-06.

The finding in 30 seconds

A DrillDownQuery ANDs all its drill-down dimensions. A two-phase drill-down, such as a range with a fast-match query, proposes candidates and confirms each with matches(). In one of DrillSideways' three scoring strategies, that confirmation is skipped for the second-cheapest dimension whenever the segment has no deletions.

  1. Index 200 documents in one segment with no deletions; four have a brand and a price: acme 5, acme 50, other 5, other 50.
  2. Run DrillSideways without an executor (the default) on a DrillDownQuery that browses all documents, with brand = acme and price in [0, 10]. The price drill-down is LongRange#getQuery with a fast-match point range [0, 100]: it takes candidates first, then checks each value.
  3. You expect 1 hit (acme at 5) and brand sideways counts acme = 1, other = 1, which searcher.count(ddq) and concurrent DrillSideways return. You get 2 hits (acme at 50 too) and acme = 2, other = 2.
  4. Why: with costs 200 (browse all), 2 (brand) and 4 (price) the scorer picks its "drill-down advance" strategy. Its test for the second dimension reads, in short, acceptDocs == null || acceptDocs.get(doc) && matches(). With no deletions acceptDocs is null, so matches() never runs and all 4 candidates count as cheap.
  5. No error; the hit and facet counts only go up (with a bounded top-N, a false hit can push a true one out of the page). One deleted document in the segment, or an executor, makes the same query correct.

Who is affected

Applications that run DrillSideways without an executor (the default) with two or more drill-downs, one of them two-phase: a range or distance drill-down with a fast-match query, a phrase, a slow doc-values query. It must sort second by cost and cost under a tenth of the base query, as with browse-all. Plain facet-label drill-downs are not affected.

Mechanism, terms and numbers731 words

Sequential DrillSideways (no executor, the default) picks one of three scoring strategies by cost. In the "drill-down advance" strategy, DrillSidewaysScorer tests the second-cheapest drill-down dimension with acceptDocs == null || acceptDocs.get(docID) && (dc.twoPhase == null || dc.twoPhase.matches()) (DrillSidewaysScorer.java:396-397). Java's && binds tighter than ||, so on a segment without deletions TwoPhaseIterator#matches() is never called and every document in that dimension's approximation counts as matching it. Such a document becomes a drill-down hit if it matches the base query and every other dimension, or a near-miss counted in one other dimension's sideways facets if it misses exactly that dimension: in the repro, brand = acme and price in [0, 10] (a LongRange#getQuery with a fast-match point range [0, 100]) over 200 documents gives hits [0, 1] instead of [0] and brand sideways counts {acme=2, other=2} instead of {acme=1, other=1}, while searcher.count(ddq) and concurrent DrillSideways are correct. It needs a two-phase drill-down (range or distance with a fast-match query, phrase, slow doc-values query) that sorts second by cost and costs under a tenth of the base query, once the query-first rule is rejected, for example with browse-all. Introduced in 5.3.0 (LUCENE-6648, 2015); every release since and main.

Terms

  • DrillDownQuery: a base query plus drill-down dimensions; "all dimensions are AND'd against each other and the base query" (DrillDownQuery.java:112-114).
  • DrillSideways: runs a DrillDownQuery and also computes, per dimension, the "sideways" counts: documents that match everything except that dimension, "so that a dimension does not disappear after the user drills down into it" (DrillSideways.java:50-52). Without an ExecutorService it uses the sequential ("standard") implementation (DrillSideways.java:352-356).
  • Two-phase query: a query whose iterator first proposes cheap candidates (the approximation) and then confirms each with TwoPhaseIterator#matches(). LongRange#getQuery(fastMatchQuery, valueSource) proposes the fast-match query's documents and confirms each by reading its value (range/LongRange.java:155-192, :322-324).

The mechanism (main ea57360912ed)

  1. DrillSidewaysQuery sorts the dimensions by approximation cost, cheapest first; a dimension with no scorer in the segment becomes an empty iterator of cost 0 (DrillSidewaysQuery.java:157-161, :181-182).
  2. DrillSidewaysScorer#score picks the strategy (DrillSidewaysScorer.java:100-147): query-first if scoreSubDocsAtOnce or base cost < sum of dimension costs / 10; else drill-down advance if there are at least 2 dimensions and dims[1] cost < base cost / 10; else union. Repro: 200 < 6 / 10 is false, 4 < 200 / 10 is true.
  3. IndexSearcher passes the segment's live docs as acceptDocs; they are null when the segment has no deletions (IndexSearcher.java:851, ScorerUtil.java:91-93).
  4. In doDrillDownAdvanceScoring the dims[0] loop checks deletions and matches() separately (:370, :373). The dims[1] loop writes acceptDocs == null || acceptDocs.get(docID) && (dc.twoPhase == null || dc.twoPhase.matches()) (:396-397), which Java reads as acceptDocs == null || (get && matches). With acceptDocs == null every candidate passes unchecked and is recorded as matching dims[1] (:399-428).
  5. The base query is then folded in with real matches (:441-473) and dims[2..] call matches() (:495). At collection a document matching the base and every dimension is a hit, one missing exactly one dimension is a near-miss of it (:517-531). So, with two dimensions and a browse-all base as in the repro, a document in dims[0] and only in dims[1]'s approximation becomes a hit, and one only in dims[1]'s approximation becomes a near-miss of dims[0], counted in dims[0]'s sideways facets.
  6. Every other check of a dimension calls matches(): query-first (:181, :287, :304), union (:620, :645), dims[0] and dims[2..]. With deletions the expression reduces to get && matches, which is correct.

The repro's numbers

docbrandpricebrand = acmein fast-match [0, 100]price in [0, 10]
0acme5yesyesyes
1acme50yesyesno
2other5noyesyes
3other50noyesno
4-199--nonono
expected (concurrent DrillSideways, searcher.count)sequential DrillSideways
hitsdoc 0docs 0 and 1
brand sideways counts{acme=1, other=1} (docs 0, 2){acme=2, other=2} (docs 0-3)

A trace of the sequential search (a print-only copy of DrillSidewaysScorer) shows acceptDocsNull=true, costs 200 / 2 / 4, strategy=drillDownAdvance, matchesCalled=false for docs 0-3, docs 0 and 1 collected as hits and docs 2 and 3 as near-misses of brand.

Exposure and likelihood484 words
  • Who is exposed. Applications that run DrillSideways without an ExecutorService on a DrillDownQuery with at least two dimensions, one added through DrillDownQuery#add(String, Query) with a two-phase query: LongRange/DoubleRange#getQuery(fastMatchQuery, valueSource) (the facet module's range and distance drill-down; DistanceFacetsExample builds one with a bounding-box fast-match query, though with a single dimension, so the demo itself is not affected), a PhraseQuery, SortedSetDocValuesField.newSlowExactQuery, NumericDocValuesField.newSlowRangeQuery, or any other query with a two-phase iterator.
  • Exact trigger. The two-phase dimension is dims[1]: second-cheapest by approximation cost. A dimension whose value is absent from the segment gets an empty cost-0 iterator and sorts first, so the two-phase dimension is then dims[1] if it is the cheapest of the rest (automatic with two dimensions; executed by an independent reproducer); with more than one such dimension the segment gets no scorer (DrillSidewaysQuery.java:171-179). Its cost is below a tenth of the base query's, query-first is not chosen, scoreSubDocsAtOnce() is false (the default), and the segment has no deletions (append-only, or freshly merged).
  • Illustration of the cost rule (arithmetic, not run): a 1,000,000-document segment, browse all, a "within 10 km" drill-down whose bounding box holds 50,000 documents and a brand drill-down with 10,000. 1,000,000 < 6,000 is false and 50,000 < 100,000 is true, so drill-down advance runs with the distance dimension as dims[1].
  • Default or opt-in. Sequential mode is the default of the 3- and 4-argument constructors; default codec, IndexWriterConfig and searcher. The two-phase drill-down comes through the method marked "Expert: add a custom drill-down subQuery", and DrillSideways is @lucene.experimental. Ordinary DrillDownQuery#add(dim, path...) drill-downs are TermQuery and are not affected.
  • Masking on 9.x and 10.x. The default LRUQueryCache there can cache a frequently used drill-down on segments of 10,000 or more documents; a cached drill-down has no two-phase iterator, so the effect depends on the cache state. The segment size is necessary, not sufficient: the default caching policy also wants to have seen the query 2 to 5 times, depending on its type, and the cache's size and cost limits apply. Main disables the query cache by default (GITHUB#14187).
  • Downstream. Elasticsearch, OpenSearch and Solr do not use DrillSideways (read-only code search), so this reaches applications that embed the facet module directly.
  • What you see. No error. Extra hits in TopDocs and the hit count, inflated drill-down facet counts and inflated sideways counts of the other dimensions. The extra documents sit just outside the requested range, radius or phrase, so they are easy to miss. The hit count and facet counts never go down, but a false hit competes for the same bounded top-N (DrillSideways.java:227-234), so it can push a true hit out of the returned page, and an inflated label can push another out of a top-K facet list. No hang, crash or index change.
  • Frequency. Deterministic once the shape is present: the repro failed 10 of 10 iterations. No prevalence was measured.

Minimal repro

The test shows that sequential DrillSideways (no executor) on a deletion-free segment never calls the two-phase matches() of the second-cheapest drill-down dimension (here a PhraseQuery) in the drill-down-advance strategy, so a doc that is only in the phrase approximation is returned as a hit.

TestDrillSidewaysTwoPhaseSecondDim.java (JUnit test)77 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 java.util.Arrays;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.document.TextField;
import org.apache.lucene.facet.taxonomy.TaxonomyReader;
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.PhraseQuery;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollectorManager;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestDrillSidewaysTwoPhaseSecondDim extends LuceneTestCase {

  public void testPhraseDrillDownSequential() throws Exception {
    String[] body = {"x y", "y x", "x y"};
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i < 40; i++) {
          Document doc = new Document();
          if (i < 2) {
            doc.add(new StringField("a", "1", Field.Store.NO));
          }
          if (i < body.length) {
            doc.add(new TextField("body", body[i], Field.Store.NO));
          }
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        searcher.setQueryCache(null);
        FacetsConfig config = new FacetsConfig();
        DrillDownQuery ddq = new DrillDownQuery(config);
        ddq.add("a", new TermQuery(new Term("a", "1")));
        ddq.add("b", new PhraseQuery("body", "x", "y"));
        TopDocs hits =
            new DrillSideways(searcher, config, (TaxonomyReader) null)
                .search(
                    ddq,
                    new TopScoreDocCollectorManager(10, Integer.MAX_VALUE),
                    List.of(new FacetsCollectorManager(), new FacetsCollectorManager()))
                .drillDownResult();
        // expected: [0] (only doc 0 has a:1 and the phrase "x y"); actual: [0, 1]
        assertEquals(List.of(0), Arrays.stream(hits.scoreDocs).map(sd -> sd.doc).toList());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-358/, 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.TestDrillSidewaysTwoPhaseSecondDim
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected: drill-down hits [0] (only doc 0 has a:1 and the phrase "x y"). Actual: [0, 1] (doc 1 has "y x"), with -ea and -da.

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

More files, environment, full output35 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: expected:<[0]> but was:<[0, 1]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.facet.TestDrillSidewaysTwoPhaseSecondDim.testPhraseDrillDownSequential(TestDrillSidewaysTwoPhaseSecondDim.java:73)

Why it looks unintended

  • The javadoc says all dimensions are AND'd and hits treat every dimension as required; searcher.count and concurrent DrillSideways return 1 hit.
  • Every other loop in the scorer calls matches(); upstream tests add two-phase drill-downs "to ensure code coverage".
  • A 2015 precedence slip: the finder's test passes on 5.2.1 and fails on 5.3.0, where parentheses alone fix it.
The full argument355 words
  • Contract. DrillDownQuery#add: "all dimensions are AND'd against each other and the base query" (DrillDownQuery.java:112-114). DrillSidewaysResult#drillDownFacetsCollector: "populated based on hits that match the full DrillDownQuery, treating all drill down dimensions as required clauses"; #drillSidewaysFacetsCollector: "Each collector exposes the hits that match on all DrillDownQuery dimensions, but treating their corresponding sideways dimension as optional" (DrillSideways.java:265-266, :272-274). The class javadoc presents the standard and concurrent implementations as alternatives that differ in speed and duplicate work, not results (DrillSideways.java:57-63). Nothing restricts the "Expert" subquery (DrillDownQuery.java:121-125).
  • The code's own intent. The same method checks matches() for dims[0] (DrillSidewaysScorer.java:373) and dims[2..] (:495); query-first and union always do (:181, :287, :304, :620, :645). Only the missing parentheses make the dims[1] check depend on deletions.
  • Upstream tests. TestDrillSideways#testRandom adds newSlowExactQuery drill-downs: "sometimes use a two-phase approach to ensure code coverage" (TestDrillSideways.java:1283-1291). Each document has no value for a dimension only with probability 4/25 (the first value is dropped with probability 1/5 and a second one is still added with probability 1/5; :1086, :1103), so a two-phase dims[1] practically never costs under a tenth of the base (read from the cost distribution, not an instrumented run). With the fix all 307 facet tests pass, so no test pins the current behaviour.
  • History. LUCENE-6648 (#7706, @jpountz, +1 by @mikemccand, 2015-07-01) replaced Filters with two-phase iteration and appended && (dc.twoPhase == null || dc.twoPhase.matches()) to the existing acceptDocs == null || acceptDocs.get(docID) without parentheses; its issue comment calls it "a simple 1:1 migration", and the same comment names Range.getQuery(fastMatchQuery, ValueSource) as code "explicitely split into a fast approximation and a slow confirmation", the shape of this doc's range test. Before it, custom drill-downs were consumed through Scorer.nextDoc(), which returns only verified matches. The finder's PhraseQuery test (one 5.x-API source) passes on 5.2.1 and fails on 5.3.0, where the parentheses alone make it pass.
  • Precedent. #12211, fixed by #12212 (2023), treated a two-phase mistake in this scorer as a bug: a two-phase base query in the query-first strategy missed hits.
The devil's-advocate check190 words

The strongest case for "intended or misuse" (built by the devil's advocate and by a reviewer from another model family, OpenAI GPT-5.6, reading only): DrillSideways is @lucene.experimental, add(String, Query) is "Expert", the drill-down-advance scorer is a package-private performance specialisation, the LongRange#getQuery javadoc itself suggests a PointRangeQuery for a static field, and nobody has reported this since 5.3.0 (2015).

Why it fails: experimental allows API changes, not wrong results, and nothing restricts the subquery. The module's own range and distance drill-downs are two-phase, and the javadoc's PointRangeQuery advice is about speed for static fields; computed values such as distances need the fast-match form, and phrases and slow doc-values queries fail the same way. Results that depend on an internal cost estimate and on whether a segment has deletions cannot be a contract, and concurrent mode and plain searches agree with the javadoc. The reviewer from another model family rated the intended case weak; the devil's advocate found no argument that survives. The experimental and Expert markers remain a weak argument for limited compatibility promises (and so for how far a fix is backported), but none of them documents approximation-only matching.

Suggested fix

Recommended Parenthesise the dims[1] condition (2 lines)

Parenthesise the second dimension's condition so the exact check runs whether or not the segment has deletions: (acceptDocs == null || acceptDocs.get(docID)) && (dc.twoPhase == null || dc.twoPhase.matches()). Two lines in DrillSidewaysScorer.java. The repro passes, and so do all 307 facet tests (assertions on and off), the demo tests and :lucene:facet:check.

-        if (acceptDocs == null
-            || acceptDocs.get(docID) && (dc.twoPhase == null || dc.twoPhase.matches())) {
+        if ((acceptDocs == null || acceptDocs.get(docID))
+            && (dc.twoPhase == null || dc.twoPhase.matches())) {
All options and trade-offs4 options · 453 words

Option A was validated in a scratch worktree at ea57360912ed (removed afterwards); B and C were not run. Until a fix ships, users can avoid the bug with an executor or scoreSubDocsAtOnce() (option D, executed).

Parenthesise the dims[1] condition (2 lines) (validated by the checks) (recommended)

Make the deletions check and the two-phase check two conjuncts, as the dims[0] loop effectively does.

-        if (acceptDocs == null
-            || acceptDocs.get(docID) && (dc.twoPhase == null || dc.twoPhase.matches())) {
+        if ((acceptDocs == null || acceptDocs.get(docID))
+            && (dc.twoPhase == null || dc.twoPhase.matches())) {

Trade-offs

On deletion-free segments the loop now calls matches() once per dims[1] candidate: the same check it already applies to every live candidate on segments with deletions, and what every other loop does. Queries that hit the bug get slower by that cost and correct. No benchmark was run.

What the checks validated

  • Through gradle at ea57360912ed: the repro fails on stock (assertions on and off) and passes with the fix.
  • With the fix: TestDrillSideways* 15 tests (the 14 upstream plus the new one), 0 failures; :lucene:facet:test :lucene:demo:test 307 facet tests including the new one (7 skipped) and 23 demo tests, 0 failures, assertions on; :lucene:facet:test with assertions off 307 tests, 0 failures (8 skipped); :lucene:facet:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) successful.
  • Earlier steps: the finder's, the independent reproducer's (7 of 7) and the devil's advocate's tests pass with the same patch; on 5.3.0 the parentheses alone make the repro pass; the bounded-exhaustive harness finds 0 mismatches in 84,278,820 checks with it.

Split the condition like the dims[0] loop

if (acceptDocs == null || acceptDocs.get(docID)) { if (dc.twoPhase == null || dc.twoPhase.matches()) { ... } }, the shape of DrillSidewaysScorer.java:370-373.

Trade-offs

Same semantics as option A with a larger diff (re-indented body). Not run.

Remove the drill-down-advance strategy

#11076 (LUCENE-10037, open) explores a single scoring implementation; a 2023 comment there commented out both doUnionScoring and doDrillDownAdvanceScoring and reported a drill-sideways benchmark run with no statistically significant difference (most point estimates slightly negative, within the noise); the maintainer replied that they were not sure that benchmark could be trusted to detect such a change.

Trade-offs

Removes the buggy loop, but it is a design change that needs its own benchmarks and discussion; not suitable as a bug fix or backport. Not run.

Workaround until a fix ships (validated by the checks)

Pass an ExecutorService (concurrent DrillSideways), or subclass DrillSideways and return true from scoreSubDocsAtOnce(), which forces query-first scoring.

Trade-offs

Concurrent mode duplicates work per dimension (class javadoc); scoreSubDocsAtOnce() warns of "some performance loss".

What the checks validated

Both give the correct result on stock main: the repro's concurrent branch passes, and the repro with scoreSubDocsAtOnce() returning true passes.

Downstream note

An application that puts a restriction into a two-phase drill-down dimension gets documents that fail it (but lie in its approximation) in hits and facet counts; a user who chooses drill-down values can steer that dimension into the affected slot. The base query, deletions and the other dimensions stay enforced, so restrictions belong there.

How it was found

The finder, an AI tool, spotted the unparenthesised condition while building a bounded-exhaustive check of DrillSidewaysScorer against an exact set-semantics reference. That harness then found mismatches only in the drill-down-advance strategy with a two-phase second dimension, and none with the two-line fix in 84,278,820 checks. No model checker or prover was involved.

The method in full243 words

Method: bounded-exhaustive translation validation, a hunt unit (facets, taxonomy, SSDV, DrillSideways), 2026-10-26.

  • The finder read DrillSidewaysScorer while designing a harness that drives the real DrillSidewaysQuery and DrillSidewaysScorer with set-backed base and drill-down queries (optional two-phase iterators), a live-docs overlay, each of the three strategies forced, documents at the 2048-document chunk boundaries (positions 0, 2047, 2048, 4095), and compares every hit and sideways set with an exact set-semantics reference.
  • On the stock main jars: 310,464 of 43,808,304 checks (4 documents, 2 dimensions) and 248,950 of 36,015,000 (3 documents, 3 dimensions) mismatched, every one in the drill-down-advance strategy; with no two-phase dimensions, 0 of 3,145,728 (5 documents, 2 dimensions); with the fix, 0 mismatches in 84,278,820 checks (4,455,516 + 36,015,000 + 43,808,304; also 2 documents, 4 dimensions).
  • The finder then wrote a public-API JUnit (PhraseQuery and slow doc-values second dimension). Then a refute review by an independent reviewer from another model family (OpenAI GPT-5.6, reading only; its "not real" was procedural, since it could not run code, and it raised no substantive refutation: apart from the failed execution, its only not-real item was the experimental/Expert scope argument, which it said documents no relaxed matching), an independent reproducer, a GPT-5.6 "works as designed" review with executed re-runs, a devil's advocate with its own range variant, an adjudication, a history sweep from 5.2.1 to main, a prior-report search, a security assessment, a registry re-run and a credibility audit. No cross-model review that executes code has run.

Prior reports, history and checks

Prior reports

No prior report: 276 read-only searches on 2026-10-26 over GitHub, JIRA, the mailing lists, downstream projects and the web, plus the changed files of all 411 open PRs. Closest: #12211 / #12212 (2023), where a two-phase base query missed hits, a different site with the opposite symptom.

Searches5 searches

26 hits were read and classified (33 GitHub items read in full); none is this bug. Limits: private security@ reports are invisible, GitHub search is token-based, and the builds@ archive returned TestDrillSideways failure mails only up to 2021-10.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2634 queries of a scripted prior-report search and 107 GraphQL phrasings (class and method names; two-phase, approximation, acceptDocs, deletions, precedence; PhraseQuery, newSlowExactQuery, newSlowRangeQuery, fastMatchQuery, LongRange; wrong or extra hits and counts; concurrent vs sequential; TestDrillSideways failures), also in apache/lucenenet, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearchno report; closest #12211 / #12212 (two-phase base query in query-first, missed hits, fixed 2023); #11803 added two-phase drill-downs to testRandom; #11076 (open) would remove the method; #10461 (2020) predates two-phase drill-downs in testRandom
Open PRs2026-10-26changed files of all 411 open apache/lucene PRs#16847 is the only one touching DrillSidewaysScorer.java, and it only changes context.reader().maxDoc() to context.maxLeafDoc() at three sites
JIRA (LUCENE)2026-10-2617 archive queries by the scripted prior-report search; 3 JIRA REST calls: a search over every issue that mentions DrillSideways (40 titles); LUCENE-6648 and LUCENE-6553 read in fullnone about two-phase drill-downs; LUCENE-6648 is the introducing change
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-2680 tool queries and 8 direct archive queriesusage questions about range dimensions only; builds@ failure mails only up to 2021-10
Downstream and web2026-10-26Elasticsearch, OpenSearch, Solr (4 queries and read-only code search); 10 web queriesno DrillSideways use downstream; Lucene.NET (based on 4.8.1) predates two-phase iteration; on the web only a usage question about range dimensions

History

Introduced in 5.3.0 (2015) by LUCENE-6648, which moved the facet module from Filters to two-phase iteration. The finder's test (API-adapted for old lines) passes on 5.2.1 and fails on all 13 releases run from 5.3.0 to 10.5.2 and on main; upstream main still has the line.

Versions tested8 rows

Introduced by LUCENE-6648 (#7706, "Remove dependency of lucene/facet on oal.search.Filter"; commit "Remove usage of oal.search.Filter in lucene/facet."), patch by @jpountz, +1 by @mikemccand, committed 2015-07-01 as 8ad6fff42a33 (branch_5x, first released in 5.3.0, tagged 2015-08-20) and bc1c535617ba (trunk, first released in 6.0.0). SVN era, no PR. In the dims[1] loop the new two-phase check was appended to the old deleted-docs test without parentheses. Before it, custom drill-downs were consumed through Scorer.nextDoc() (verified matches) and a random-access Filter forced query-first scoring.

Since then only LUCENE-9570 (7f8b7ffbcad2, 2021-01-03) moved the line break; LUCENE-6919, #11803 and #11881 changed two-phase code in the file without touching this condition. 94 release tags in the history clone contain the commit; the 13 releases in the table were run, the others rest on reading the source. Not checked: x86, the taxonomy-facet path (same scorer), releases between those listed.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedthis doc's test fails through gradle and the runner, assertions on and off; the finder's, the reproducer's and the devil's advocate's tests fail too
upstream main e1c4184cb983 and a9779ef3c193, branch_10x, branch_10_6 readline unchanged (396-397 on main, 401-402 on the branches), 2026-10-26; no CHANGES entry
10.5.2, 10.0.0 affectedfail (10.5.2: this doc's test with -ea and -da)
9.12.3, 9.0.0 affectedfail (9.12.3: this doc's test)
8.0.0, 8.11.2, 8.11.4 affectedfail (old-API variant)
6.0.0, 6.6.6, 7.0.0, 7.7.3 affectedfail; the deletion control passes
5.3.0, 5.5.5 first affectedfail (5.3.0 also -da, 20 of 20 seeds); on 5.3.0 adding only the parentheses makes it pass
5.2.1 last unaffectedthe finder's PhraseQuery test (the 5.x-API source that fails on 5.3.0) passes 2 of 2 with -ea and -da and 40 of 40 over 20 seeds; a strategy probe shows the same drill-down-advance path, so the pass is not vacuous

Independent checks

An independent reproducer, a reviewer from another model family (reading only), a devil's advocate and an adjudication all say real; the adjudication and a registry audit re-ran with fresh seeds. Every executed check fails on stock and passes with the two-line fix.

Checks and verdicts652 words

The cross-model reviews could only read code; every executed check was run by the hunt's own AI tools and runner.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateBounded-exhaustive mismatches only in drill-down advance, 0 with the fix; the 2-test JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3, and passes with the fix.
Refute review (OpenAI GPT-5.6, reading only) not real (low), proceduralIt could not run code and raised no substantive refutation: 9 of its 12 evidence items support real and 1 is neutral; apart from the failed execution, its only not-real item was the experimental/Expert scope argument, which it said documents no relaxed matching. A separate run re-ran the repro: 2 of 2 fail.
Independent reproducer realOwn 7-test JUnit with newSlowRangeQuery: 3 fail on main (assertions on and off, 10 iterations), 10.5.2 and 9.12.3; wrong SSDV facet counts; the absent-value trigger; 4 controls pass; 7 of 7 with the fix.
Works-as-designed review (OpenAI GPT-5.6, reading only) and re-runs real, mediumRated the intended case weak, agreed with the root cause; separate re-runs fail on main with three seeds and on 10.5.2, and pass with the fix.
Devil's advocate real, highNo javadoc, CHANGES, MIGRATE, test or issue allows it; its LongRange fast-match variant fails on main and 10.5.2 and passes with the fix.
Adjudication confirmed, highRe-ran the independent reproduction's test with fresh seed 7A11E5ED: 7 run, 3 failed with assertions on and off, 4 controls pass; read the introducing diff.
Security assessment none (not security-sensitive; was "out of scope of SECURITY.md"), public routeFail-open over-match only for restrictions put into a two-phase drill-down; base query, live docs and other dimensions stay enforced; out of scope of SECURITY.md.
Registry re-run and audit keepThe finder's test fails 2 of 2 with fresh seed 5EED4A0D (assertions on and off) and passes with the fix; the range variant fails with assertions off.
This doc's test and fix validation reproduced; fix validatedA new one-test JUnit fails through gradle and the runner on main, 10.5.2 and 9.12.3; with the fix it and the whole facet and demo suites pass, and :lucene:facet:check -x test succeeds.
Independent fact check 2 text errors and 9 minor findings, all fixedRe-ran the repro on main (assertions on and off, a fresh seed), on the 10.5.2 and 9.12.3 jars, with the fix and with one deletion, and checked every cited line, quote, commit, count and GitHub item. It found the fixed-harness total misstated (84,278,820, not 84,279,000) and the refute review summarised too strongly; both are corrected here, with the 9 minor wordings (the LUCENE-6648 title, #11076, #16847, the disclosure route and others).
Cross-model fact check (OpenAI GPT-5.6, reading only) real; 8 text errors, all fixedRead-only review against the code and the run logs: the bug is real and the fix works. 8 errors (1 high), 6 overclaims and 7 missing caveats, among them which documents become hits versus sideways counts, which repro ran on which releases, the absent-value trigger, bounded top-N displacement, the 4/25 test probability, the stale upstream SHA and the #11076 benchmark; all taken in this doc.
Correction to the registry row 4 wordings out of dateThe registry row (fact strip and registry card) still has wordings that the checks corrected in this doc: found_by says "0 mismatches in 84,279,000 checks" (84,278,820 is right); title and summary say every near-miss is "returned as drill-down hits" (only those that match the base query and every other dimension; one that misses exactly one other dimension becomes a sideways count of that dimension); first_affected_version says "the same repro source" passes on 5.2.1 (the finder's 5.x-API PhraseQuery test); upstream says "26 hits read in full" (read and classified) and that #16847 "only renames maxDoc" (it changes context.reader().maxDoc() to context.maxLeafDoc()).

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,587 words

Finding record

LFV-358, snapshot of 2026-11-06

Registry title
Sequential DrillSideways skips the two-phase matches() check of the second-cheapest drill-down dimension on segments without deletions, so documents that fail that dimension but are in its approximation are returned as drill-down hits and counted in the facets (released since 5.3.0)
Area
facet / DrillSideways (sequential DrillSidewaysScorer, drill-down-advance strategy, two-phase drill-down dimensions)
Priority
Medium, rank 35: Default sequential DrillSideways has returned wrong drill-down hits and facet counts on ordinary deletion-free segments since 5.3, but only when a two-phase dimension is the second-cheapest.
Impact class
silent wrong results
Impact note
Applications that run DrillSideways without an executor (the default sequential mode) on a DrillDownQuery with two or more dimensions, one of them a two-phase query (a range or distance drill-down from LongRange/DoubleRange#getQuery with a fast-match query, as in the facet demo's distance example, or a PhraseQuery, slow doc-values query or similar added through the Expert DrillDownQuery#add(String, Query)), silently get extra drill-down hits and inflated drill-down and sideways facet counts on every segment without deletions where that dimension is the second-cheapest by approximation cost and costs less than a tenth of a broad base query (for example browse-all); default codec, IndexWriterConfig and searcher, ordinary scale, every release from 5.3.0 to 10.6.0 (10.6.0 by reading) and main; plain DrillDownQuery#add(dim, path...) drill-downs (TermQuery), concurrent DrillSideways and segments with deletions are correct, and on 9.x/10.x the default query cache can mask it on segments of 10,000 or more docs once it caches the drill-down.
Summary
You run DrillSideways without an executor on a DrillDownQuery with two or more dimensions, one of which is a two-phase query such as a distance or range drill-down with a fast-match query or a PhraseQuery, and expect the hits to match every dimension and each dimension's sideways counts to cover the documents that match all the other dimensions. Instead, on a segment with no deleted documents, when that dimension is the second-cheapest by approximation cost and the base query is broad, DrillSidewaysScorer never calls its TwoPhaseIterator#matches(), because the condition 'acceptDocs == null || acceptDocs.get(docID) && (dc.twoPhase == null || dc.twoPhase.matches())' parses as 'acceptDocs == null || (get && matches)'. So every document in that dimension's approximation (inside the bounding box but outside the radius, the phrase words in another order) is treated as matching it, returned as a hit and counted in the facets, while concurrent DrillSideways and plain boolean queries on the same searcher give the correct sets.
Introduced
8ad6fff42a333d8ddc1dd45702828b1e210808c1 (branch_5x, svn r1688672; first released in 5.3.0), the backport of trunk bc1c535617ba0c366a6ab86d8ad733e7cc784f99 (svn r1688661; first released in 6.0.0): LUCENE-6648 'Remove usage of oal.search.Filter in lucene/facet' (migrated as GitHub issue #7706), patch by GitHub jpountz, +1 by GitHub mikemccand; SVN era, no PR [read: the commit introduced the approximation/twoPhase iteration in DrillSidewaysScorer and appended '&& (dc.twoPhase == null || dc.twoPhase.matches())' after the existing 'acceptDocs == null || acceptDocs.get(docID)' without parentheses (DrillSidewaysScorer.java:270-271 in 5.3.0); the condition text is unchanged since, LUCENE-9570 7f8b7ffbcad2 (2021-01-03) only moved the line break; later two-phase edits LUCENE-6919 ddb3a97678f, #11803 d02ba3134ff and #11881 c66a5590500 did not touch it] [exec: the identical repro passes on 5.2.1 and fails on 5.3.0; on 5.3.0 adding only the parentheses makes it pass]. A regression: before LUCENE-6648 a custom drill-down Query was consumed through Scorer.nextDoc() (verified matches) and a random-access Filter forced the query-first strategy (2015-07-01 (both commits; 5.3.0 tagged 2015-08-20))
First affected version
5.3.0 [exec] (last unaffected 5.2.1 [exec]: the same repro source passes there 2 of 2 with -ea and -da and 40 of 40 over 20 seeds, and a strategy probe shows doDrillDownAdvanceScoring running with the phrase dimension as dims[1], so the pass is non-vacuous; DrillSideways and DrillDownQuery#add(String, Query) already existed in 5.2.1; the trunk commit first shipped in 6.0.0)
Affected versions
every release from 5.3.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed [read: 94 release tags in the history clone contain the commit and the condition is unchanged in every tag inspected]; executed with repro.sh (seed DEADBEEF, JDK 25.0.3 aarch64, SHA-1-verified release jars, API-adapted sources for old lines): FAIL on 5.3.0 (also -da; 20 of 20 seeds), 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), each with the deletion control passing where it exists [exec, follow-up and finder]; PASS on 5.2.1 [exec]; the independent reproduction's own JUnit fails on 10.5.2 (-ea, -da) and 9.12.3 (-ea), the intended-behaviour check's range-facet variant and the cross-model review's re-run fail on 10.5.2 [exec]. Still unfixed on upstream main e1c4184cb983, branch_10x and branch_10_6 (lines 401-402 there; read 2026-10-26). Not checked: x86, the taxonomy-facet path (same scorer), patch releases not listed
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; a fix PR is open upstream.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new, 276 read-only queries: a scripted prior-report search over GitHub (34), commits (5), Elasticsearch/OpenSearch/Solr (4), the dev@, java-user@, issues@, commits@ and builds@ lists (80), JIRA (17) and the web (10); 107 GitHub GraphQL phrasings including apache/lucenenet, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; JIRA REST over every LUCENE issue that mentions DrillSideways (40 titles screened); 8 direct list-archive queries; the changed files of all 411 open apache/lucene PRs; local git history; 26 hits read in full, all same_bug false). Closest, not this bug: #12211, fixed by #12212 (merged 2023-03-29, backport #12217): a two-phase BASE query in the query-first strategy missed hits, a different site with the opposite symptom; #11803 (merged 2022-09) added the two-phase drill-downs to testRandom; #11076 (open, LUCENE-10037) proposes one scoring implementation, which would remove the method, with no PR and no bug report; #10461 / LUCENE-9421 (open, undiagnosed testRandom failure) predates two-phase drill-downs in testRandom, so it cannot be this bug; #16847 (open) is the only open PR touching DrillSidewaysScorer.java and only renames maxDoc. Not fixed on upstream main e1c4184cb983, branch_10x or branch_10_6; no CHANGES.txt entry. Gaps: private reports to security@lucene.apache.org cannot be searched; GitHub search is token-based; the builds@ archive search returned TestDrillSideways failure mails only up to 2021-10; searches cannot prove absence
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26): this doc's test through gradle in a scratch worktree (stock fails with assertions on and off; with the fix it passes, and so do all 307 facet tests with assertions on and off, the 23 demo tests and :lucene:facet:check -x test; the worktree was removed afterwards) and through the standalone JUnit runner on main (-ea, -da, 10 iterations) and on the Maven Central jars of 10.5.2 (-ea, -da) and 9.12.3 (-ea); the finder's PhraseQuery repro (API-adapted sources for the old lines) on 5.2.1 (pass) and on 5.3.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.11.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (fail); upstream main e1c4184cb983 and, later the same day, a9779ef3c193, plus branch_10x and branch_10_6, read on 2026-10-26 (line unchanged).

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:100-112 (baseQueryCost, drillDownCost, drillDownAdvancedCost = dims[1] cost), 130-147 (strategy choice: query-first if scoreSubDocsAtOnce or baseQueryCost < drillDownCost / 10, else drill-down-advance if numDims > 1 and dims[1] cost < baseQueryCost / 10, else union), 367-373 (dims[0] checks matches()), 396-397 (the dims[1] condition), 485-495 (dims[2..] check matches())
  • lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysQuery.java:155-182 (a null drill-down scorer becomes an empty ConstantScoreScorer of cost 0; nullCount > 1 returns no scorer; dims sorted by approximation cost)
  • lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:264-279 (DrillSidewaysResult javadoc: drill-down hits treat all dimensions as required; each sideways collector relaxes only its own dimension)
  • lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:111-125 ('all dimensions are AND'd against each other and the base query'; add(dim, path...) builds a TermQuery; 'Expert: add a custom drill-down subQuery')
  • lucene/facet/src/test/org/apache/lucene/facet/TestDrillSideways.java:1283-1291 (newSlowExactQuery drill-downs 'to ensure code coverage')
  • lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:239-245 (DrillDownQuery.add with DoubleRange#getQuery(bounding-box fast-match query, value source), run through a sequential DrillSideways)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:850-852 (acceptDocs = ScorerUtil.likelyLiveDocs(getLiveDocs()), null when the segment has no deletions)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:100-147 (costs and strategy choice), 334-541 (doDrillDownAdvanceScoring), 370-373 (dims[0] checks), 393-431 (dims[1] loop; condition at 396-397), 441-473 (base fold), 495 (dims[2..] matches), 517-531 (collect), 181, 287, 304, 620, 645 (other matches() calls)
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysQuery.java:157-161 (null scorer -> empty iterator, cost 0), 181-182 (sort by approximation cost)
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:50-52 (class javadoc), 352-356 and 424-428 (sequential when executor is null); DrillDownQuery.java:112-114 (AND'd)
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:155-192 (TwoPhaseIterator over the fast-match approximation), 322-324 (getQuery)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:851; ScorerUtil.java:91-93
Who is affected
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:116-119 (add(dim, path) builds a TermQuery), 121-125 ('Expert: add a custom drill-down subQuery'); DrillSideways.java:69 (@lucene.experimental), 89-112 (constructors without executor), 245-250 (scoreSubDocsAtOnce default false)
  • Lucene main ea57360912ed: lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:239-245 (one-dimension DrillDownQuery with DoubleRange#getQuery(bounding box, value source))
  • Lucene main ea57360912ed: lucene/CHANGES.txt:325 (GITHUB#14187 query cache disabled by default)
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysQuery.java:171-179 (nullCount > 1: no scorer); DrillSideways.java:227-234 (bounded TopScoreDocCollectorManager); DrillSidewaysScorer.java:528-531 (collectHit / collectNearMiss)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillDownQuery.java:112-114, 121-125; DrillSideways.java:57-63, 265-266, 272-274
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:181, 287, 304, 373, 396-397, 495, 620, 645
  • Lucene main ea57360912ed: lucene/facet/src/test/org/apache/lucene/facet/TestDrillSideways.java:1086, 1283-1291
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:310-324 (getQuery javadoc: PointRangeQuery may be faster for a static value source)
Suggested fix
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/DrillSidewaysScorer.java:370-373; DrillSideways.java:57-63, 241-250

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

Repro files

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.