DrillSideways computes facet counts for a drill-down query plus "sideways" counts for each drilled dimension. It runs those searches one after another on your thread, or in parallel on an ExecutorService that you pass to its constructor.
- A drill-down query narrows the hits, say to color = red. Drill sideways also counts the other colors as if red were not selected, so the user can switch: one extra search per drilled dimension.
new DrillSideways(searcher, config, state)runs these searches one after another on your thread. The 5-argument constructor with anExecutorServiceruns them as parallel tasks.- In Java, when a task on an executor throws,
Future.get()throws anExecutionExceptionwhose cause is the task's exception. Code that collects the results has to unwrap it;IndexSearcherhas done so since 9.8.0.
DrillSideways throws the task's exception itself; with an ExecutorService it arrives as RuntimeException -> ExecutionException -> the same exception. Select the still to open it at full size. Generated with AI, checked against the code.- Your application runs faceted search with
DrillSidewaysand catchesIndexSearcher.TooManyClausesto answer "query too large". A user's drill-down query has a base query with 1,026 term clauses; the default limit is 1,024. - Without an executor,
search(query, 10)throwsIndexSearcher$TooManyClauses: maxClauseCount is set to 1024, and your catch block handles it. - You pass an
ExecutorServiceto the constructor so that the drill-down and drill-sideways searches run in parallel. You expect the same exception: the javadoc presents the two modes as alternatives that differ in speed and duplicate work. - Instead you get
RuntimeException: ExecutionException: TooManyClauses, two levels deep, and your catch block is skipped. A firedExitableDirectoryReadertimeout, anIOExceptionand anErrorarrive wrapped the same way. - Why: the concurrent path reads each task's
Futureand turns any task failure intothrow new RuntimeException(e)around theExecutionException. The search still fails and returns nothing; in every tested case only the exception's type differs.
Applications that pass an ExecutorService to DrillSideways (opt-in; the default constructors are sequential) and catch TooManyClauses, ExitingReaderException, IOException or Error by type, for example to answer "query too large" or "timed out". No wrong results: the search still fails, with the real exception two causes down.
Mechanism, terms and numbers601 words
DrillSideways runs a drill-down search plus one drill-sideways search per drilled dimension, either one after another on the caller's thread or, when an ExecutorService is passed to its 5-argument constructor, as parallel tasks. In the parallel mode, searchConcurrently collects the tasks with Future.get and turns any task failure into throw new RuntimeException(e) around the ExecutionException. So the caller gets RuntimeException -> ExecutionException -> the original exception, where the sequential mode throws the original itself: the clause limit (IndexSearcher$TooManyClauses), a fired ExitableDirectoryReader timeout, the IOException every search overload declares, or an Error. Handlers that catch those types do not fire. The search still fails and returns nothing, and the original exception with its message stays in the cause chain. IndexSearcher stopped wrapping the same way in 9.8.0 (#12516) to make sequential and concurrent exceptions the same, and the facet module's other concurrent code already rethrows the cause. Opt-in mode of an @lucene.experimental class; every release from 6.4.0 to 10.6.0 and main.
- Drill down / drill sideways: a
DrillDownQueryis a base query plus one filter per drilled dimension.DrillSidewaysreturns its hits and facet counts, and for each drilled dimension the counts computed without that dimension's filter (the "sideways" counts). - Sequential and concurrent mode: the 3- and 4-argument constructors pass no executor (
DrillSideways.java:89-112); the 5-argument one takes anExecutorService("Use this constructor to use the concurrent implementation",:114-125). The class javadoc presents the two as alternatives that differ in speed and duplicate work (:57-64).
- The executor picks the path. Every
searchoverload is declaredthrows IOExceptionand ends inif (executor != null) searchConcurrently(...) else searchSequentially(...)(DrillSideways.java:352-355,:424-428). - Sequential.
searchSequentially(:441-481) runs oneDrillSidewaysQuerywithsearcher.search(...)on the caller's thread (:467); a failure propagates as thrown. - Concurrent.
searchConcurrently(:483-522, nothrowsclause) wraps each search in aCallableCollectorwhosecall()issearcher.search(query, collectorManager)(:302-310), submits the drill-down task (:506), runs the drill-sideways tasks withinvokeAll(:507-508) and reads them withFuture.get(:510,:514).Future.getreports a task's exception as anExecutionException, and the catch wraps that again:catch (ExecutionException e) { throw new RuntimeException(e); }(:519-520). - Result.
RuntimeException(message: theExecutionException'stoString()) ->ExecutionException-> the original exception. So anIOExceptionthrown inside a search task never reaches the caller as anIOExceptionin this mode (one thrown afterwards on the caller's thread, for example bybuildFacetsResultat:390, still can).
Any exception a task throws is wrapped: from rewrite, weight creation, scoring or collection, including what an executor-backed IndexSearcher rethrows from its own slices. Not affected: argument checks and facet building on the caller's thread, a RejectedExecutionException from the executor (propagates unchanged), and interrupts, which take a separate ThreadInterruptedException path (:517-518).
Minimal repro (one empty document, a base query whose createWeight throws IOException("simulated read failure"), one drill-down dimension): sequential java.io.IOException: simulated read failure; with a single-thread executor java.lang.RuntimeException: java.util.concurrent.ExecutionException: java.io.IOException: simulated read failure, thrown at DrillSideways.java:520. Finder's test: a base query of 1,026 term clauses (two nested BooleanQuerys of 513; limit 1,024) gives IndexSearcher$TooManyClauses: maxClauseCount is set to 1024 sequentially and RuntimeException: ExecutionException: IndexSearcher$TooManyClauses: ... with a 2-thread pool; a fired ExitableDirectoryReader timeout the same with ExitingReaderException. Verifiers added an IOException from a hit collector (wrapped even when the IndexSearcher has its own executor, which itself throws it as-is), a 1,100-term TermRangeQuery with SCORING_BOOLEAN_REWRITE, and a simulated StackOverflowError.
Exposure and likelihood267 words
- Who is hit. Code that runs
DrillSidewayswith an executor and handles failures by type:catch (IndexSearcher.TooManyClauses e)for "query too large",catch (ExitingReaderException e)for "timed out",catch (IOException e), which the signature invites, or treatingErroras fatal. In the concurrent mode all of them miss, and the failure takes the generic error path instead. - Opt-in. Only the 5-argument constructor with a non-null
ExecutorServiceis affected; the 3- and 4-argument constructors are sequential.DrillSidewaysis@lucene.experimental. - Effect. For the tested failures, only the outer exception type differs. The search still aborts and returns nothing, every task has finished before the wrapper is thrown (
invokeAllandFuture.getwait for them,DrillSideways.java:506-516), and the original exception and message stay in the cause chain. (The separate interrupt path does not wait for the drill-down task; it is not part of this bug.) Handlers that catchRuntimeException(asTooManyClausesandExitingReaderExceptionare) or unwrap the cause chain are not affected. - Scale. None: one document and any failing task.
- Not tested: identical query semantics. Sequential mode runs one
DrillSidewaysQuery, whosevisitonly callsvisitLeaf(this)(DrillSidewaysQuery.java:121-124); concurrent mode runs theDrillDownQueryplus one query per dimension (DrillSideways.java:488-503). Whether a visitor-based check such as the nested clause count can then fail in one mode only was not tested; that would be a separate issue. - Likelihood: plausible. The triggers are ordinary for user-facing search (a query over 1,024 clauses, a reader timeout under load, an I/O error), but it only bites type-specific handlers, and the recorded searches found no report of it since 2017.
One JUnit test in lucene/facet, public API only: a base query whose createWeight throws IOException, one document, and the same DrillDownQuery searched without and then with a single-thread executor. The first expectThrows passes; the second fails. Deterministic; it fails with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.facet;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.QueryVisitor;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.Weight;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.NamedThreadFactory;
public class TestDrillSidewaysExecutorExceptionType extends LuceneTestCase {
public void testExecutorThrowsSameExceptionAsSequential() throws Exception {
Query failing =
new Query() {
@Override
public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost)
throws IOException {
throw new IOException("simulated read failure");
}
@Override
public String toString(String field) {
return "failing";
}
@Override
public void visit(QueryVisitor visitor) {}
@Override
public boolean equals(Object other) {
return other == this;
}
@Override
public int hashCode() {
return System.identityHashCode(this);
}
};
ExecutorService executor = Executors.newSingleThreadExecutor(new NamedThreadFactory("ds"));
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
FacetsConfig config = new FacetsConfig();
DrillDownQuery query = new DrillDownQuery(config, failing);
query.add("dim", "value");
expectThrows(
IOException.class,
() -> new DrillSideways(searcher, config, null, null, null).search(query, 10));
// Expected: the same IOException; actual: RuntimeException(ExecutionException(IOException)).
expectThrows(
IOException.class,
() -> new DrillSideways(searcher, config, null, null, executor).search(query, 10));
}
} finally {
executor.shutdown();
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
}
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or a later commit where DrillSideways.java is unchanged)
cp $REPRO/TestDrillSidewaysExecutorExceptionType.java lucene/facet/src/test/org/apache/lucene/facet/
./gradlew :lucene:facet:test --tests org.apache.lucene.facet.TestDrillSidewaysExecutorExceptionType -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
- Expected
- Both calls throw
java.io.IOException: simulated read failure, the exception the sequential call throws. - Observed
- With the executor:
RuntimeException: java.util.concurrent.ExecutionException: java.io.IOException: simulated read failure, thrown atDrillSideways.searchConcurrently(line 520).
More files, environment, full output266 words
One class, one method (89 lines with the license header). The anonymous Query is the smallest stock-API failure: no clause-limit setup and no timeout wiring. One empty document suffices because the failure happens at weight creation; one drill-down dimension makes the concurrent path run one drill-down and one drill-sideways task. The taxonomy reader and SortedSetDocValuesReaderState are null because the search fails before any facets are built. The stock triggers (the 1,026-clause query, a fired timeout) fail through the same catch.
As a regression test it is the suggested one, unchanged except for one edit that ./gradlew :lucene:facet:check needs: google-java-format wraps the 101-character comment before the second expectThrows onto two lines. With that edit and the fix, the module's checks pass (see Suggested fix).
| lucene | main 645d4fe14a20: affected; also the 10.6.0 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
# main 645d4fe14a20, ./gradlew :lucene:facet:test --tests ...TestDrillSidewaysExecutorExceptionType -Ptests.seed=DEADBEEF -Ptests.asserts=true
# (-Ptests.asserts=false: the same failure); module prefixes of frames and some frames elided
TestDrillSidewaysExecutorExceptionType > testExecutorThrowsSameExceptionAsSequential FAILED
junit.framework.AssertionFailedError: Unexpected exception type, expected IOException but got java.lang.RuntimeException: java.util.concurrent.ExecutionException: java.io.IOException: simulated read failure
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D357657FD11EB4DD]:0)
at org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at org.apache.lucene.facet.TestDrillSidewaysExecutorExceptionType.testExecutorThrowsSameExceptionAsSequential(TestDrillSidewaysExecutorExceptionType.java:80)
Caused by:
java.lang.RuntimeException: java.util.concurrent.ExecutionException: java.io.IOException: simulated read failure
at org.apache.lucene.facet.DrillSideways.searchConcurrently(DrillSideways.java:520)
at org.apache.lucene.facet.DrillSideways.search(DrillSideways.java:353)
at org.apache.lucene.facet.DrillSideways.search(DrillSideways.java:234)
at org.apache.lucene.facet.DrillSideways.search(DrillSideways.java:221)
Caused by:
java.util.concurrent.ExecutionException: java.io.IOException: simulated read failure
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:124)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:193)
at org.apache.lucene.facet.DrillSideways.searchConcurrently(DrillSideways.java:510)
Caused by:
java.io.IOException: simulated read failure
at org.apache.lucene.facet.TestDrillSidewaysExecutorExceptionType$1.createWeight(TestDrillSidewaysExecutorExceptionType.java:45)
at org.apache.lucene.search.IndexSearcher.createWeight(IndexSearcher.java:978)
...
:lucene:facet:test (FAILURE): 1 test, 1 failure
# line 80 is the second expectThrows (with the executor); the first, sequential one passed
# 10.6.0 release jars (standalone runner, -ea and -da): the same exception chain- The javadoc presents the two modes as alternatives differing in speed and duplicate work; every
searchoverload declaresIOException, which a failing task cannot throw as such in that mode. - #12516 (9.8.0) removed the same wrapping from
IndexSearcherto unify sequential and concurrent exceptions; it touched no facet file. - The facet module's other concurrent code already rethrows the task's exception.
The full argument275 words
- The javadoc (
DrillSideways.java:57-64): "There is both a "standard" and "concurrent" implementation for drill sideways. The concurrent approach is enabled by providing anExecutorServiceto the ctor. The concurrent implementation may be a little faster but does duplicate work ... The standard approach may be a little slower but avoids duplicate computations and query processing." Nothing about exceptions. (Added by #142, LUCENE-9944; in every release from 9.0.0 on.) - The signature. Every
searchoverload declaresthrows IOException(:188-190,:220,:225-226,:326-328,:406-410); in the concurrent mode anIOExceptionthrown by a search task only ever arrives wrapped in an uncheckedRuntimeException. - #12516 (Luca Cavanna, 9.8.0,
CHANGES.txt:3429-3432, "Changes in runtime behavior": "Unwrap and throw execution exceptions cause when performing concurrent search") madeTaskExecutorrethrow the cause (TaskExecutor.java:118-134). Its description: "This unifies the exceptions that get thrown between sequential codepath (when no executor is provided) and concurrent codepath (when an executor is provided)." Besides itsCHANGES.txtentry it changed only core files; it touched no facet file and never mentionsDrillSideways. - The module's convention.
ConcurrentSortedSetDocValuesFacetCounts.java:310-313and:342-345, andDynamicRangeUtil.java:92-93, rethrow the cause withIOUtils.rethrowAlways. - Tests.
TestParallelDrillSideways extends TestDrillSidewaysand only swaps in the executor constructors, so the whole suite runs in both modes; no test makes aDrillSideways.searchcall fail, and no facet test mentionsExecutionException, so neither shape is pinned. - Intent at introduction. In LUCENE-7588 (#8639) mikemccand asked to fold the parallel version into
DrillSideways: "It's really an implementation/execution detail I think? Similar to how IndexSearcher takes an optional executor." The thread never discusses how task failures reach the caller.
The devil's-advocate check215 words
Strongest case (refute review, the cross-model review, the intended-behaviour check, adjudication): DrillSideways is @lucene.experimental; no javadoc promises the same exception types in both modes, and throws IOException does not forbid unchecked wrappers; the wrapping is as old as the concurrent mode (2017) and matched IndexSearcher until 9.7.0, where TestLRUQueryCache still has the branch "expected: wrapped when executor is in use" (TestLRUQueryCache.java:1500-1504); and upstream filed #12516 under "Changes in runtime behavior", not "Bug Fixes".
Why it fails: the wrapping was never documented or discussed as a choice; it matched the IndexSearcher of its time, whose maintainers later removed the wrapping as the "root cause" (javanna, #12516 review) of mismatched exceptions; the module's other concurrent code unwraps; and the declared IOException, when a search task throws it, cannot arrive as one. The verifiers rated the case moderate as history and weak as a defence. Verdict: not intended; a low-priority consistency fix following #12516.
No-noise check. The call fails loudly either way and the cause is preserved. It is still claimed, as low priority, because the inputs are ordinary (clause limit, timeout, I/O error), the effect is visible to users (type-based handlers take the wrong branch), and Lucene treated the identical pattern in IndexSearcher as worth changing. The hunt's noise audit rated it "claim-low".
Recommended A: rethrow the task's own exception
In searchConcurrently, declare IOException and replace throw new RuntimeException(e) with throw IOUtils.rethrowAlways(cause), as TaskExecutor (#12516) and the facet module's other concurrent code do. The repro then passes, and so does the whole lucene/facet test suite (307 tests).
final List<? extends CollectorManager<K, R>> drillSidewaysCollectorManagers)
throws IOException {
...
} catch (ExecutionException e) {
// Rethrow the task's own exception, as the sequential path and IndexSearcher do
Throwable cause = e.getCause();
throw IOUtils.rethrowAlways(cause != null ? cause : e);
}All options and trade-offs3 options ยท 420 words
Option A was run through gradle on a scratch worktree of 645d4fe14a20 (seed DEADBEEF); B and C were not run.
+6/-2 in DrillSideways.java: import org.apache.lucene.util.IOUtils, declare throws IOException on searchConcurrently (both callers already declare it), and rethrow the ExecutionException's cause, null-guarded as in ConcurrentSortedSetDocValuesFacetCounts.java:310-313. IOUtils.rethrowAlways throws an IOException, RuntimeException or Error as itself and wraps anything else in a RuntimeException (IOUtils.java:495-513); a task here can only throw what IndexSearcher.search throws. This is the shape #12516 gave TaskExecutor, where jpountz suggested IOUtils.rethrowAlways in review.
final List<? extends CollectorManager<K, R>> drillSidewaysCollectorManagers)
throws IOException {
...
} catch (ExecutionException e) {
// Rethrow the task's own exception, as the sequential path and IndexSearcher do
Throwable cause = e.getCause();
throw IOUtils.rethrowAlways(cause != null ? cause : e);
}Trade-offs
- A behaviour change for callers that today unwrap
RuntimeException -> ExecutionExceptionby hand: they now get the exception itself. #12516 shipped the same change under "Changes in runtime behavior", where a CHANGES entry belongs. - When several tasks fail, only one exception is thrown, as today (the drill-down task's, else the first failing drill-sideways task's); the others are not added as suppressed.
- Interrupt handling is unchanged.
What the checks validated
- The repro passes with assertions on and off (1 test each).
TestDrillSideways(14 tests),TestParallelDrillSideways(14) and the repro: 29 tests, 0 failures.- The whole
lucene/facettest suite: 40 suites, 307 tests, 8 skipped, 0 failures. ./gradlew :lucene:facet:check -x test(google-java-format, ecj lint, forbidden APIs) passes with the fix and the test, once its 101-character comment is wrapped onto two lines; unwrapped, only google-java-format objects.- The wrapped (PR-ready) copy passes with the fix with assertions off; it was not run with assertions on, and differs from the repro only in that comment.
- Earlier, the independent reproduction's equivalent fix (+3/-2) made the independent reproduction's four-trigger test, the finder's test and the history probe pass on
a9779ef3c193.
new TaskExecutor(executor).invokeAll(...) unwraps the cause and adds secondary failures as suppressed (TaskExecutor.java:118-134).
Trade-offs
TaskExecutor also runs tasks on the calling thread (TaskExecutor.java:103-114), which changes how DrillSideways uses the caller's executor: a larger change than the bug needs.
A javadoc note on the executor constructor.
Trade-offs
Settles the contract without a behaviour change, but keeps the declared IOException unreachable and the mismatch with the sequential mode, IndexSearcher and the rest of the facet module.
A read-only code search found no use of DrillSideways in Elasticsearch, OpenSearch or Solr, so the reach is applications that call the facet module directly.
An enumerated differential sweep: 13 calls into Lucene APIs that take an executor, each run under 9 executor behaviours and compared with the same call without an executor. A failing DrillSideways search differed in all 9 cells. The finder, an AI tool, reduced it to a public-API JUnit; no model checker or solver was involved.
The method in full157 words
Method: semi-formal (the hunt's tag; an enumerated differential sweep, borderline with ordinary differential testing).
- A hunt unit (executor pluggable-behaviour sweep), 2026-10-26, on main
a9779ef3c193: 13 API surfaces x 9 executor behaviours (caller-runs, single thread, rejecting at the 1st/2nd/3rdexecute, shut down, LIFO pool, pool thread interrupted before task 1 or 2) = 117 cells, each compared with the executor-less call: same result, same exception class and message, returns within 8 s, executor terminates. - The row "DrillSideways.search(failing-base)", a base query that throws
IOExceptionon one segment, differed in all 9 executor cells:IOExceptionsequentially,RuntimeException(ExecutionException(IOException))(or the executor's ownRejectedExecutionExceptionfor the rejecting executors) with an executor. The successfulDrillSidewayspath matched in every non-rejecting cell. - The finder reduced it to two stock triggers, the clause limit and an
ExitableDirectoryReadertimeout, in a public-API JUnit (2 of 2 fail with assertions on and off); the minimal repro step later reduced that to the one-method test above.
No prior report: two runs of the scripted prior-report search over GitHub, commits, the mailing lists, JIRA and the web (363 and 98 hits), 43 manual searches and the files of all open PRs (2026-10-27), refreshed on 2026-10-31. Closest: #12516, which removed the same wrapping from IndexSearcher and touched no facet file.
Searches4 searches
No issue, PR, review comment, commit, JIRA entry, list thread, downstream report or web page reports this wrapping. Keyword searches cannot prove absence, and private security reports cannot be searched.
Closest, not this bug: #12516 (the same pattern fixed in core, a precedent with a different fix site); #12498 and #12160 (the discussions behind it, core only); #8639 / LUCENE-7588 (introduced the wrapping); open #16707 (edits searchConcurrently, keeps the catch); open #15221 (early termination); open #12278 (IndexSearcher keeps running slices after one fails: cancellation, not exception type); open #12032 (sequential DrillSideways with an IndexSearcher timeout throws IllegalArgumentException: same area, different bug).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs | 2026-10-27 | the scripted prior-report search (two vocabularies) and 28 manual searches in apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NET; the full text of every high- or medium-overlap hit | 25 items recorded, none the same bug; #12516 is the closest (precedent) |
| Open PRs | 2026-10-27 | the changed files of all 409 open apache/lucene PRs, plus the 18 updated since 2026-10-26 | only #16707 (keeps the catch) and #15221 touch DrillSideways.java |
| Lists, JIRA, commits, web | 2026-10-27 | dev@, java-user@, issues@, commits@ (15 manual searches plus the tool), the JIRA archive, git log -S on the line, 6 web queries | no report; a 2021 dev@ CI thread shows a wrapped stack from the pre-9.8 IndexSearcher, about another issue |
| Refresh (GitHub) | 2026-10-31 | issues and PRs created since 2026-10-26 mentioning DrillSideways; searches for searchConcurrently and DrillSideways with ExecutionException / executor exception; commits to DrillSideways.java on main | nothing new; the newest commit to the file is 54283150a816 (2026-06-14, build tooling) |
Wrong since the concurrent mode was added by LUCENE-7588 (6.4.0, 2017); every release since has the same catch line. Until 9.7.0 IndexSearcher with an executor wrapped the same way; since #12516 (9.8.0) concurrent DrillSideways is the odd one out. Executed from 6.3.0 (no concurrent mode) to 10.6.0 and main; not fixed upstream.
Versions tested5 rows
Introduced by LUCENE-7588 (migrated as #8639, "A parallel DrillSideways implementation"; commit subject "LUCENE-7588: DrillSideways can now run its queries concurrently"): a patch by Emmanuel Keller, committed by Mike McCandless on 2017-01-03 as b4a002f7d88 (master, first released in 7.0.0) and 5b6401b212d (branch_6x, first released in 6.4.0). Its first version of the concurrent search already had the catch; git blame at 645d4fe14a20 puts DrillSideways.java:517-521 on b4a002f7d88, and later refactors (#142, #632, #13568, #13702) kept it.
Became the odd one out with #12516 (947b2c5e5a9 on main, backport 0a38f90e35b in 9.8.0), which made IndexSearcher rethrow the cause. Before 9.8.0 the mismatch was only against sequential DrillSideways. All 77 release tags from 6.4.0 to 10.5.2 contain the unchanged line. Upstream main 2f424fd428da, branch_11x and branch_10_6 (2026-10-30) still have it. Old releases ran on JDK 25, newer than they were built for; the probe's non-failing control passed on every version.
| Version or commit | Verdict | Result |
|---|---|---|
| 6.3.0 | no such API | no ExecutorService constructor |
| 6.4.0, 6.4.1, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.11.4, 9.0.0 | first affected: 6.4.0 | history probe: clause limit and fired timeout wrapped with an executor (6.4.0 with assertions on and off) |
| 9.1.0, 9.7.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2 | affected | the finder's test fails 2 of 2; on 9.7.0 IndexSearcher with an executor wraps too, from 9.8.0 only DrillSideways does |
| 10.6.0 | affected | this doc's test fails, assertions on and off |
| main 645d4fe14a20 | affected | this doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A |
A reviewer from another model family (OpenAI GPT-5.6, executed), an independent reproducer, a second GPT-5.6 review with a works-as-designed lens, a devil's advocate and an adjudication all rated it real with high confidence; a credibility audit kept it, and the noise audit rated it low priority but worth claiming.
Checks and verdicts316 words
The hunt's checks ran on main a9779ef3c193 (the GPT-5.6 reviews on the previous main ea57360912ed, whose facet module and TaskExecutor are identical); this doc's runs on 645d4fe14a20.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | The 117-cell sweep flagged the row; the reduced JUnit fails 2 of 2 on main (assertions on and off), 10.5.2, 9.12.3 and 9.1.0. |
| Refute review (OpenAI GPT-5.6, executed) | real (high) | Own JUnit: a checked IOException from createWeight and a TooManyClauses from a PrefixQuery rewrite are wrapped; its fix passes 3 of 3 and keeps TestDrillSideways and TestParallelDrillSideways green. Intended argument moderate. |
| Independent reproducer | real | Own four-trigger JUnit fails 4 of 4 on main, 10.5.2 and 9.12.3; its fix (+3/-2) makes all pass. |
| Works-as-designed review (OpenAI GPT-5.6, executed) | real (high) | An IOException from Query.rewrite is wrapped on main and 10.5.2; the intended case is outweighed by #12516 and the facet siblings. |
| Devil's advocate | real (high) | Inherited legacy, not a contract; a probe shows every path that uses an executor wrapping on 9.7.0 (three of its four paths; the fourth uses none) and only DrillSideways(executor) from 9.8.0. Corrected the #12516 release (9.8.0, not only 10.0.0). |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test (4 of 4 fail, assertions on and off) and the fix (4 of 4 pass); wrong error, no duplicate. |
| Credibility audit | keep | Re-ran the finder's and the independent reproduction's tests with fresh seeds and the fix; real but minor. |
| Noise audit | claim-low | Only the exception type is wrong, but type-based handlers miss; asked for one minimal test, which the doc uses. |
| This doc's runs (2026-10-31) | reproduced; fix validated | On 645d4fe14a20 the test fails through gradle with assertions on and off and on the 10.6.0 jars; with fix A it passes, with the whole lucene/facet suite (307 tests) and the module's checks. |
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 507 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,524 words
- Registry title
- DrillSideways with an ExecutorService reports every failure of its drill-down and drill-sideways searches as RuntimeException(ExecutionException(cause)), while sequential DrillSideways, and IndexSearcher with an executor since 9.8.0, throw the cause itself (for example IndexSearcher.TooManyClauses, ExitableDirectoryReader.ExitingReaderException or the declared IOException)
- Area
- facet / DrillSideways concurrent mode (ExecutorService constructor, searchConcurrently)
- Priority
- Low, rank 832: Opt-in concurrent DrillSideways wraps every task failure in RuntimeException(ExecutionException), but the search still aborts with the cause in the chain.
- Impact class
- wrong error
- Impact note
- Applications that pass an ExecutorService to DrillSideways (the opt-in concurrent mode of the @lucene.experimental facet helper; a read-only code search found no use of DrillSideways in Elasticsearch, OpenSearch or Solr) get every failure of a drill-down or drill-sideways task, such as the clause limit (IndexSearcher.TooManyClauses), a fired ExitableDirectoryReader timeout (ExitingReaderException), an IOException that search() declares, or an Error, as an unchecked RuntimeException wrapping an ExecutionException wrapping the original, so handlers that catch those types by class (for example to answer 'query too large' or 'timed out') take their generic error path instead; the search still aborts and returns nothing, and the original exception and its message stay in the cause chain; any index size, default codec, IndexWriterConfig and searcher, every release from 6.4.0 to 10.6.0 and main.
- Summary
- You construct DrillSideways with an ExecutorService so that its drill-down and drill-sideways searches run concurrently, and one of those searches fails, for example because the base query has more clauses than IndexSearcher's clause limit, an ExitableDirectoryReader timeout fires, or a query or collector throws IOException. You expect the exception the sequential DrillSideways throws for the same search: the class javadoc presents the two modes as alternative implementations that differ in speed and duplicate work, every search overload declares IOException, and IndexSearcher has rethrown task failures as-is since 9.8.0 (GITHUB#12516) precisely so that sequential and concurrent searches throw the same exceptions. Instead DrillSideways.searchConcurrently catches the ExecutionException from Future.get and throws new RuntimeException(e), so the caller receives RuntimeException -> ExecutionException -> the original exception, and catch blocks for TooManyClauses, ExitingReaderException or IOException do not fire.
- Introduced
- b4a002f7d88a2383852e2bfd95b39bf7f6e33f2f (master, first released in 7.0.0) and its branch_6x twin 5b6401b212da883188f45709d1f68addbbdf2c98 (first released in 6.4.0), both 'LUCENE-7588: DrillSideways can now run its queries concurrently' (JIRA LUCENE-7588, migrated as apache/lucene#8639 'A parallel DrillSideways implementation'; a JIRA-era patch committed by GitHub mikemccand, no PR). The commit created the concurrent mode (the ExecutorService constructor and search(DrillDownQuery, CollectorManager)), and its first version of the concurrent search method already had 'catch (ExecutionException e) { throw new RuntimeException(e); }' (6.4.0 DrillSideways.java:436-437) [read: git blame on main a9779ef3c193 attributes DrillSideways.java:517-521 (both catches) to b4a002f7d88; git log -S finds no other commit that touches the line; the refactors #142, #632, #13568 (304d4e7855d) and #13702 (c21bc5405be) rewrote the try body and kept the catch; the issue's description and 26 comments never discuss how task failures reach the caller] [exec: 6.3.0 has no ExecutorService constructor, 6.4.0 is affected] (2017-01-03 (both commits); first release 6.4.0, tagged 2017-01-17)
- First affected version
- 6.4.0 [exec: the history probe is affected with -ea and -da, both triggers]; 6.3.0 is not affected because the concurrent mode does not exist there (no ExecutorService constructor) [exec: probe API_ABSENT]
- Affected versions
- every release from 6.4.0 through 10.6.0, and main a9779ef3c193 and 645d4fe14a20; never fixed. [exec] the minimal test fails on the 10.6.0 release jars and on main 645d4fe14a20, -ea and -da (2026-10-31); the follow-up's version-portable history probe (clause limit and fired query timeout, sequential versus ExecutorService, plain IndexSearcher, 11 segments, a non-failing control that passes everywhere) is affected on 6.4.0 (-ea and -da), 6.4.1, 6.5.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.11.4, 9.0.0, 9.7.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), and passes on main with the fix shadowed; the finder's JUnit fails 2 of 2 on 9.1.0, 9.7.0, 9.8.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's JUnit fails 4 of 4 on 9.12.3, 10.5.2 and main; the intended-behaviour check's probe shows the wrapping on 9.7.0, 9.8.0 and main. [read] all 77 release tags from 6.4.0 to 10.5.2 in the git history contain the unchanged catch line (8.9.0-8.11.3, released from the old combined repository, are inferred from the 8.8.2 and 8.11.4 runs), and so do upstream main a95c754ee00, branch_10x 1309534296e and branch_10_6 2763db11b0a (refs fetched 2026-10-26). By version: from 6.4.0 to 9.7.0 IndexSearcher with an executor wrapped in-slice failures the same way, so the mismatch then was against sequential DrillSideways (the intended-behaviour check probe on 9.7.0: three of four executor paths wrap); since 9.8.0 (GITHUB#12516) concurrent DrillSideways is the only one of these paths that still wraps [exec 9.8.0, 9.12.3, 10.0.0, 10.5.2, main]. Not checked: x86; on old versions only search(DrillDownQuery, int) was executed (the other overloads reach the same catch [read 6.4.0 and main])
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record verdict new): scripted prior-report search 1 (24 GitHub issue/PR, 4 commit, 4 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA and 6 web queries; 363 distinct hits) and run 2 with a different vocabulary ('sequential and concurrent', 'exception consistency'; 47 queries, 98 hits); 28 manual GitHub searches in apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Lucene.NET; 15 manual list searches; the full text of every high- or medium-overlap GitHub hit read (25 items recorded, all same_bug false); the changed files of all 409 open apache/lucene PRs plus the 18 updated since 2026-10-26: only #16707 (edits searchConcurrently but keeps the catch) and #15221 (early-terminating collector manager) touch DrillSideways.java; the finder's and the intended-behaviour check's searches agree (the intended-behaviour check: 25 queries, 118 hits, 0 high or medium overlap). Closest, not this bug: GITHUB#12516 (merged 2023-09-05, 9.8.0 'Changes in runtime behavior', by GitHub javanna) removed the same wrapping from IndexSearcher/TaskExecutor and AbstractKnnVectorQuery 'to unify the exceptions that get thrown between sequential codepath ... and concurrent codepath', touched no facet file and never mentions DrillSideways (a precedent with a different fix site); #12498 and #12160 (the discussions behind #12516, core only); #8639 / LUCENE-7588 (introduced the wrapping, exceptions never discussed); #12278 (open: IndexSearcher keeps running slices after one fails, cancellation not exception type); #12032 (open: sequential DrillSideways with an IndexSearcher timeout throws IllegalArgumentException, same area, different bug); #15220/#15221, #13753 and #16707 (open, other DrillSideways work). Not fixed upstream: live main and branch_10x still have the catch at DrillSideways.java:519-520 (read through the GitHub contents API; the newest commit touching the file is 54283150a816, 2026-06-14, build tooling). Gaps: GitHub search reads one page per query, one web query was rate-limited, private security@ reports are invisible, searches cannot prove absence
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner (seeds DEADBEEF and 00C0FFEE); the same test on the 10.6.0 release jars, assertions on and off. The hunt's tests and a version-portable probe ran on 17 releases from 6.3.0 (no concurrent mode) to 10.5.2 and on maina9779ef3c193, whose facet module andTaskExecutorare identical to645d4fe14a20. The fix was run through gradle in a scratch worktree of645d4fe14a20on 2026-10-31. Upstream main2f424fd428da,branch_11xd0c0decc6606andbranch_10_691eaab4fb298(2026-10-30) still have the same line.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:57-64 (at a9779ef3c193) (class javadoc: 'standard' and 'concurrent' implementations, enabled by providing an ExecutorService, differ in speed and duplicate work), :69 (@lucene.experimental), :115-125 (ExecutorService constructor: 'Use this constructor to use the concurrent implementation'), :188-260 (search overloads declare IOException and route through search(query, CollectorManager)), :352-353 and :424-425 (executor != null -> searchConcurrently), :483-522 (searchConcurrently: submit at :506, invokeAll at :507-508, Future.get at :510 and :514, ThreadInterruptedException at :517-518, 'throw new RuntimeException(e)' at :519-520)lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:118-134 (at a9779ef3c193) (collectResults: catch ExecutionException -> IOUtils.useOrSuppress(exc, e.getCause()), then throw IOUtils.rethrowAlways(exc)); lucene/facet/src/java/org/apache/lucene/facet/sortedset/ConcurrentSortedSetDocValuesFacetCounts.java:313 and :345 and lucene/facet/src/java/org/apache/lucene/facet/range/DynamicRangeUtil.java:93 (facet siblings rethrow the cause); lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:1501-1504 ('expected: wrapped when executor is in use', the pre-9.8.0 IndexSearcher branch); no diff in lucene/facet or TaskExecutor.java between ea57360912ed and a9779ef3c193 (git diff --stat)https://github.com/apache/lucene/pull/12516 (precedent: IndexSearcher/TaskExecutor unwrapping, 9.8.0), https://github.com/apache/lucene/issues/8639 (LUCENE-7588, introducing change)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:57-64, (at 645d4fe14a20) :69, :89-125, :188-260, :302-310, :326-355, :406-428, :441-481, :483-522
- Who is affected
lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:69, (at 645d4fe14a20) :89-125lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:1175; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:45 (both RuntimeExceptions)
- Why it looks unintended
lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:57-64, (at 645d4fe14a20) :188-190, :220, :225-226, :326-328, :406-410lucene/facet/src/java/org/apache/lucene/facet/sortedset/ConcurrentSortedSetDocValuesFacetCounts.java:310-313, (at 645d4fe14a20) :342-345; lucene/facet/src/java/org/apache/lucene/facet/range/DynamicRangeUtil.java:92-93lucene/facet/src/test/org/apache/lucene/facet/TestParallelDrillSideways.java:31, (at 645d4fe14a20) :47-93; lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:1500-1504
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/IOUtils.java:495-513; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:103-134lucene/facet/src/java/org/apache/lucene/facet/sortedset/ConcurrentSortedSetDocValuesFacetCounts.java:310-313 (at 645d4fe14a20)
- Diagrams
lucene/facet/src/java/org/apache/lucene/facet/DrillSideways.java:352-355, (at 645d4fe14a20) :467, :506-520
- TestDrillSidewaysExecutorExceptionType.java (1 test):
repro/LFV-636/src/org/apache/lucene/facet/TestDrillSidewaysExecutorExceptionType.java(89 lines)
A picture of the two call paths and the two layers of wrapping, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.
assets/LFV-636/bug.png(507 KB)
