All findings · Lucene finding · LFV-636 · drillsideways-concurrent-wraps-task-exceptions

DrillSideways with an ExecutorService wraps failures of its searches in RuntimeException(ExecutionException), unlike the sequential mode

With an ExecutorService, DrillSideways reports any failure inside its searches (the clause limit, a query timeout, the declared IOException) as RuntimeException -> ExecutionException -> the real exception, while the sequential mode throws the real one, so catch blocks for those types are skipped. Opt-in; 6.4.0 to 10.6.0 and main.

Summary

Priority
Low, rank 832Opt-in concurrent DrillSideways wraps every task failure in RuntimeException(ExecutionException), but the search still aborts with the cause in the chain.
Impact
wrong error
Since
6.4.0 (LUCENE-7588, 2017)introduced 2017-01-03 · about 9 years
Affected
6.4.0 to 10.6.0 and main
Status
documented
Found by
a differential sweep of APIs that take an executor

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

The finding in 30 seconds

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.

Background: drill sideways and Java futures

  1. 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.
  2. new DrillSideways(searcher, config, state) runs these searches one after another on your thread. The 5-argument constructor with an ExecutorService runs them as parallel tasks.
  3. In Java, when a task on an executor throws, Future.get() throws an ExecutionException whose cause is the task's exception. Code that collects the results has to unwrap it; IndexSearcher has done so since 9.8.0.
Diagram of DrillSideways.search on two paths. Without an executor the search runs on the caller's thread and the exception (IOException, TooManyClauses or ExitingReaderException) reaches the caller as is, so a catch block for its type fires. With an ExecutorService the searches run as tasks; Future.get wraps the task's exception in ExecutionException, and searchConcurrently wraps that in RuntimeException, so the catch block is skipped.
The same failing drill-down search on two paths: sequential 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.

The finding, step by step

  1. Your application runs faceted search with DrillSideways and catches IndexSearcher.TooManyClauses to 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.
  2. Without an executor, search(query, 10) throws IndexSearcher$TooManyClauses: maxClauseCount is set to 1024, and your catch block handles it.
  3. You pass an ExecutorService to 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.
  4. Instead you get RuntimeException: ExecutionException: TooManyClauses, two levels deep, and your catch block is skipped. A fired ExitableDirectoryReader timeout, an IOException and an Error arrive wrapped the same way.
  5. Why: the concurrent path reads each task's Future and turns any task failure into throw new RuntimeException(e) around the ExecutionException. The search still fails and returns nothing; in every tested case only the exception's type differs.

Who is affected

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.

Terms

  • Drill down / drill sideways: a DrillDownQuery is a base query plus one filter per drilled dimension. DrillSideways returns 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 an ExecutorService ("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 mechanism (main 645d4fe14a20)

  1. The executor picks the path. Every search overload is declared throws IOException and ends in if (executor != null) searchConcurrently(...) else searchSequentially(...) (DrillSideways.java:352-355, :424-428).
  2. Sequential. searchSequentially (:441-481) runs one DrillSidewaysQuery with searcher.search(...) on the caller's thread (:467); a failure propagates as thrown.
  3. Concurrent. searchConcurrently (:483-522, no throws clause) wraps each search in a CallableCollector whose call() is searcher.search(query, collectorManager) (:302-310), submits the drill-down task (:506), runs the drill-sideways tasks with invokeAll (:507-508) and reads them with Future.get (:510, :514). Future.get reports a task's exception as an ExecutionException, and the catch wraps that again: catch (ExecutionException e) { throw new RuntimeException(e); } (:519-520).
  4. Result. RuntimeException (message: the ExecutionException's toString()) -> ExecutionException -> the original exception. So an IOException thrown inside a search task never reaches the caller as an IOException in this mode (one thrown afterwards on the caller's thread, for example by buildFacetsResult at :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).

The numbers

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 DrillSideways with 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 treating Error as 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 ExecutorService is affected; the 3- and 4-argument constructors are sequential. DrillSideways is @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 (invokeAll and Future.get wait 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 catch RuntimeException (as TooManyClauses and ExitingReaderException are) 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, whose visit only calls visitLeaf(this) (DrillSidewaysQuery.java:121-124); concurrent mode runs the DrillDownQuery plus 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.

Minimal repro

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.

TestDrillSidewaysExecutorExceptionType.java (1 test)89 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.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));
    }
  }
}
Command
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 at DrillSideways.searchConcurrently (line 520).

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

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

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0 release jars with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# 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

Why it looks unintended

  • The javadoc presents the two modes as alternatives differing in speed and duplicate work; every search overload declares IOException, which a failing task cannot throw as such in that mode.
  • #12516 (9.8.0) removed the same wrapping from IndexSearcher to 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 an ExecutorService to 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 search overload declares throws IOException (:188-190, :220, :225-226, :326-328, :406-410); in the concurrent mode an IOException thrown by a search task only ever arrives wrapped in an unchecked RuntimeException.
  • #12516 (Luca Cavanna, 9.8.0, CHANGES.txt:3429-3432, "Changes in runtime behavior": "Unwrap and throw execution exceptions cause when performing concurrent search") made TaskExecutor rethrow 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 its CHANGES.txt entry it changed only core files; it touched no facet file and never mentions DrillSideways.
  • The module's convention. ConcurrentSortedSetDocValuesFacetCounts.java:310-313 and :342-345, and DynamicRangeUtil.java:92-93, rethrow the cause with IOUtils.rethrowAlways.
  • Tests. TestParallelDrillSideways extends TestDrillSideways and only swaps in the executor constructors, so the whole suite runs in both modes; no test makes a DrillSideways.search call fail, and no facet test mentions ExecutionException, 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".

Suggested fix

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.

A: rethrow the task's own exception (validated by the checks) (recommended)

+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 -> ExecutionException by 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/facet test 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.

B (not run): run the tasks through TaskExecutor

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.

C (not recommended): document the wrapping

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.

Downstream note

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.

How it was found

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/3rd execute, 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 IOException on one segment, differed in all 9 executor cells: IOException sequentially, RuntimeException(ExecutionException(IOException)) (or the executor's own RejectedExecutionException for the rejecting executors) with an executor. The successful DrillSideways path matched in every non-rejecting cell.
  • The finder reduced it to two stock triggers, the clause limit and an ExitableDirectoryReader timeout, 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.

Prior reports, history and checks

Prior reports

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

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs2026-10-27the 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 hit25 items recorded, none the same bug; #12516 is the closest (precedent)
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRs, plus the 18 updated since 2026-10-26only #16707 (keeps the catch) and #15221 touch DrillSideways.java
Lists, JIRA, commits, web2026-10-27dev@, java-user@, issues@, commits@ (15 manual searches plus the tool), the JIRA archive, git log -S on the line, 6 web queriesno report; a 2021 dev@ CI thread shows a wrapped stack from the pre-9.8 IndexSearcher, about another issue
Refresh (GitHub)2026-10-31issues and PRs created since 2026-10-26 mentioning DrillSideways; searches for searchConcurrently and DrillSideways with ExecutionException / executor exception; commits to DrillSideways.java on mainnothing new; the newest commit to the file is 54283150a816 (2026-06-14, build tooling)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
6.3.0 no such APIno 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.0history 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 affectedthe 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 affectedthis doc's test fails, assertions on and off
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateThe 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 realOwn 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 keepRe-ran the finder's and the independent reproduction's tests with fresh seeds and the fix; real but minor.
Noise audit claim-lowOnly 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 validatedOn 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,524 words

Finding record

LFV-636, snapshot of 2026-11-06

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 main a9779ef3c193, whose facet module and TaskExecutor are identical to 645d4fe14a20. The fix was run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31. Upstream main 2f424fd428da, branch_11x d0c0decc6606 and branch_10_6 91eaab4fb298 (2026-10-30) still have the same line.

Lucene and upstream sources

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-125
  • lucene/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-410
  • lucene/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-93
  • lucene/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-134
  • lucene/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

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

Repro files

About the diagrams

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.

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.