All findings · Lucene finding · LFV-045 · indexsearcher-count-partition-hang

IndexSearcher#count can hang forever when Weight#count throws on a segment split into intra-segment partitions

With an executor and intra-segment partitions, IndexSearcher#count can hang forever when Weight#count throws in a segment's first partition, for example on an ExitableDirectoryReader timeout: the other partition waits forever on a future nobody completes. Affected: every release 10.0.0 to 10.5.2 and main; an unsplit segment throws as it should.

Summary

Priority
Medium, rank 19Once opt-in intra-segment partitions are enabled with an executor, any count whose timeout fires or whose Weight#count throws hangs forever and parks executor threads, a denial of service that defeats the timeout in 10.0-10.5.2, but only direct Lucene users are exposed (Elasticsearch is not).
Impact
hang
Since
10.0.0 (#13542, 2024)introduced 2024-09-10 · about 2 years
Affected
10.0.0 to 10.5.2 (12 releases), branch_10_6 and main
Status
documented
Found by
TLA+ model checking (TLC deadlock trace)

AI-assisted page: this finding was first signalled by a formal tool's own output 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

count splits the work per partition, and the partitions of one segment coordinate through a shared future. The bug is a missing failure path in that coordination.

Summary card titled "Lucene: IndexSearcher#count hangs forever when a query times out on a split segment". Panel 1: you count on a segment split into partitions, with a timeout: an ExitableDirectoryReader with QueryTimeoutImpl(0), an IndexSearcher with a pool and slices(leaves, 1000, 1, true), one segment of 2000 docs split into [0, 1000) and [1000, 2000); expected: count throws ExitingReaderException; actual: it never returns. Panel 2: the caller thread's partition puts future F in the map, then Weight#count throws; the pool thread's partition finds F and waits in F.get() (line 131, no timeout); F is never completed because only CollectionTerminatedException is caught (line 122); TaskExecutor cancels only tasks that have not started and waits for the pool task in collectResults (TaskExecutor.java:123). Below: the fix (catch Throwable, completeExceptionally, rethrow) and the affected versions, 10.0.0 to 10.5.2 and main.
One 2,000-document segment split into two partitions, a spent time budget: the caller's partition registers future F and its Weight#count throws; the pool thread's partition waits on F forever, and TaskExecutor waits for it. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one segment of 2,000 documents, all f:a. Give IndexSearcher a 1-thread pool and the documented opt-in slices(leaves, 1000, 1, true): the segment becomes two partitions, [0, 1000) and [1000, 2000). Wrap the reader in ExitableDirectoryReader with a spent budget, new QueryTimeoutImpl(0).
  2. Call searcher.count(new TermQuery(new Term("f", "a"))). You expect ExitingReaderException, as when the segment is not split: 16 of 16 such calls threw.
  3. Inside, the first partition to arrive puts an empty future F into a map shared by the segment's partitions and calls Weight#count. The other partition waits in F.get(), with no timeout, for its answer.
  4. Weight#count throws, but F is completed only on success or CollectionTerminatedException. TaskExecutor cancels only tasks that have not started and waits for the rest, so count never returns: 12 of 16 calls hung on main (-da), each with the caller and a pool thread parked for good.
  5. Same on every release 10.0.0 to 10.5.2. Completing F exceptionally when Weight#count fails (5 lines) gives 0 hangs in 200 calls per test.

Who is affected

Direct Lucene users who give IndexSearcher an executor and turn on intra-segment partitions, then call count, a TotalHitCountCollectorManager search or facets with n == 0, when Weight#count throws, for example on a timeout or an I/O error. Not the default; Elasticsearch is not exposed. In the recorded runs most failing counts hung (11-15 of 16 calls on main).

Mechanism, terms and numbers602 words

With an executor and the opt-in intra-segment partitions, IndexSearcher#count uses a partition-aware collector in TotalHitCountCollectorManager. The first partition of a segment to arrive registers an incomplete CompletableFuture and calls Weight#count; the other partitions of that segment wait on it with no timeout. The future is completed only on success or CollectionTerminatedException, so when Weight#count throws anything else (a stock ExitableDirectoryReader timeout or an IOException, both run), the other partitions wait forever, TaskExecutor waits for them, and count never returns. Introduced by #13542 in 10.0.0; every release to 10.5.2, branch_10_6 and main are affected. Each hanging call pins its threads for good.

Terms

  • Intra-segment partitions (10.0.0, #13542): a segment can be split into doc-id ranges that are searched as separate slices on separate threads. Off by default. You turn it on by overriding IndexSearcher#slices(List) to call the public static slices(leaves, maxDocsPerSlice, maxSegmentsPerSlice, true), which splits any segment with more than maxDocsPerSlice documents into at most 5 partitions of maxDoc / n documents, the last one also taking the remainder.
  • Weight#count(ctx): the per-segment shortcut that count tries first, for example TermQuery's docFreq. It is declared to throw IOException.
  • The count barrier: with partitions, TotalHitCountCollectorManager hands out a LeafPartitionAwareTotalHitCountCollector. Per segment, the first partition decides "count from statistics" or "collect", and the others follow, so that a whole-segment count is never added twice and the query cache cannot answer some partitions with a whole-segment count.

The bug (main ea57360912ed)

  1. IndexSearcher#count builds new TotalHitCountCollectorManager(getSlices()) (IndexSearcher.java:525), makes one task per slice and calls TaskExecutor#invokeAll (:778-788), which forks all tasks but one and runs tasks on the calling thread too (TaskExecutor.java:91-114).
  2. Each task calls collector.setWeight(weight) (IndexSearcher.java:811); its first call for a partition is then collector.getLeafCollector(ctx) (:836). The first partition of the segment puts a new, incomplete CompletableFuture F into the shared map with putIfAbsent (TotalHitCountCollectorManager.java:110-115) and calls Weight#count through super.getLeafCollector (TotalHitCountCollector.java:48).
  3. F is completed only on success (complete(false), :120) or on CollectionTerminatedException (complete(true), :122-124). There is no other catch and no finally. Every other partition of the segment waits in earlyTerminated.get() with no timeout (:131).
  4. When Weight#count throws anything else, F stays incomplete. The failing task's setException cancels only tasks that have not started; cancel does nothing to a started task, by design (TaskExecutor.java:175-198). collectResults then waits with an untimed future.get() (:118-123).
  5. So the caller parks in collectResults while a pool thread parks at line 131, or, when the caller ran the waiting partition itself, the caller parks at line 131 after the producing thread has already finished. Nothing can complete F any more: the hang is permanent.

The stock trigger: count uses a no-scores weight, so TermQuery looks its term up lazily inside Weight#count (TermQuery.java:257). On an ExitableDirectoryReader that opens an ExitableTermsEnum, whose constructor checks the timeout (and the thread's interrupt flag) and throws ExitingReaderException (ExitableDirectoryReader.java:950-952, :976-999). IndexSearcher#setTimeout does not reach this path: it wraps only the bulk scorer, after getLeafCollector (IndexSearcher.java:842-858). The stock trigger needs a segment without deletions (with deletions TermWeight#count returns -1 without opening the terms enum, TermQuery.java:255-266, and the timeout then throws normally later), the timeout detected inside the first partition's Weight#count (here: a budget already spent), no query cache answering the count first (off by default on main), and the other partition's task already started.

The same collector serves search(q, TotalHitCountCollectorManager), MultiCollectorManager wrappers and FacetsCollectorManager.search(searcher, q, 0, fcm) (its n == 0 branch).

Exposure and likelihood357 words
  • Who is exposed. Applications that pass an Executor to IndexSearcher and override slices(List) with slices(..., true), and that call IndexSearcher#count, search(q, TotalHitCountCollectorManager), a MultiCollectorManager containing one, or FacetsCollectorManager.search(searcher, q, 0, fcm).
  • What triggers it. Any exception from the first-arriving partition's Weight#count: a time budget enforced through ExitableDirectoryReader (or an interrupt it detects), an IOException or a custom Weight (these run), or, by reading, an AlreadyClosedException when a reader is closed under a running search (close to misuse: SearcherManager's acquire/release keeps the reader open while a search runs).
  • Not exposed. Searchers without an executor (one whole-segment slice), default slicing, and a sequential Runnable::run executor (the second task is cancelled before it starts). Downstream, read only: Elasticsearch builds whole-segment slices; OpenSearch has intra-segment partitioning but counts with its own manager, and no path into Lucene's class was established.
  • How likely. It hangs whenever the other partition's task has started before the first one throws. With no test hooks: 124 and 176 of 200 calls in the study (50-doc segment, 5 partitions, 4 threads), 121 of 200 at ea57360912ed, 11-15 of 16 in the tie-break, 44 of 50 on 10.5.2 and 48 of 50 on 10.0.0. These rates hold for the recorded 1- and 4-thread pools on one host; other pool sizes were not measured.
  • Damage. The call never returns, with no exception and no log line. The calling thread and every thread running a waiting partition of the segment (at most 4 per split segment) stay parked until each is interrupted; usually that is the caller plus a pool thread, but in 1 of 12 tie-break hangs on 10.0.0 and 1 of 27 hangs in the fact check's run on main the caller ran the waiting partition itself and only it was pinned. IndexSearcher's javadoc advises against shutdownNow on the search executor with NIOFSDirectory. With a fixed pool the leak stops once every worker is parked (later searches then run on their calling threads, TaskExecutor.java:102-114); how far a service degrades depends on its sizing (not measured).
  • Scale. None needed: 10 documents in the test, 2,000 in the hook-free check.

Minimal repro

The test shows that IndexSearcher#count with an executor and intra-segment partitions never returns when the first partition's Weight#count throws (here a stock ExitableDirectoryReader timeout).

TestIndexSearcherCountPartitionHang.java (JUnit test)83 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.search;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Phaser;
import java.util.concurrent.TimeUnit;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.ExitableDirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndexSearcherCountPartitionHang extends LuceneTestCase {

  public void testCountRethrowsTimeoutFromPartitionedSegment() throws Exception {
    ExecutorService pool = Executors.newFixedThreadPool(2);
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 2; i++) {
          Document doc = new Document();
          doc.add(new StringField("f", "a", Field.Store.NO));
          w.addDocument(doc);
        }
      }
      Phaser bothPartitionsStarted = new Phaser(2);
      try (DirectoryReader reader =
          new ExitableDirectoryReader(DirectoryReader.open(dir), () -> true)) {
        IndexSearcher searcher =
            new IndexSearcher(reader, pool) {
              @Override
              protected LeafSlice[] slices(List<LeafReaderContext> leaves) {
                return slices(leaves, 1, 1, true);
              }

              @Override
              protected void searchLeaf(
                  LeafReaderContext ctx,
                  int minDocId,
                  int maxDocId,
                  Weight weight,
                  Collector collector)
                  throws IOException {
                bothPartitionsStarted.arriveAndAwaitAdvance();
                super.searchLeaf(ctx, minDocId, maxDocId, weight, collector);
              }
            };
        Future<Integer> count =
            pool.submit(() -> searcher.count(new TermQuery(new Term("f", "a"))));
        // expected: count() rethrows the timeout thrown by Weight#count; actual: it never returns
        expectThrows(ExecutionException.class, () -> count.get(10, TimeUnit.SECONDS));
      }
    } finally {
      pool.shutdownNow();
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-045/, 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: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.search.TestIndexSearcherCountPartitionHang
Expected vs actual
Expected: count() rethrows the exception (a bounded get sees ExecutionException); actual: the other partition parks in TotalHitCountCollectorManager (line 131) and the 10 s get sees TimeoutException.

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

More files, environment, full output45 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
    => junit.framework.AssertionFailedError: Unexpected exception type, expected ExecutionException but got java.util.concurrent.TimeoutException
       org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1437)
       org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
       org.apache.lucene.search.TestIndexSearcherCountPartitionHang.testCountRethrowsTimeoutFromPartitionedSegment(TestIndexSearcherCountPartitionHang.java:77)
     Caused by: java.util.concurrent.TimeoutException
       java.base/java.util.concurrent.FutureTask.get(FutureTask.java:206)
       org.apache.lucene.search.TestIndexSearcherCountPartitionHang.lambda$testCountRethrowsTimeoutFromPartitionedSegment$2(TestIndexSearcherCountPartitionHang.java:77)
       org.apache.lucene.tests.util.LuceneTestCaseParent._expectThrows(LuceneTestCaseParent.java:1587)
       org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1427)
       [...]

Why it looks unintended

  • TaskExecutor#invokeAll promises the exception is rethrown, not a hang; ExitableDirectoryReader promises an ExitingReaderException on timeout.
  • The unsplit segment throws promptly, and the code already catches ExecutionException from the shared future, but nothing ever completes it exceptionally.
  • CollectorManager makes per-segment coordination the manager's job; count and the manager are not @lucene.experimental (much of the path around them is).
The full argument209 words
  • TaskExecutor#invokeAll (TaskExecutor.java:71-75): "If an exception is thrown by more than one callable, the subsequent ones will be added as suppressed exceptions to the first one that was caught. Additionally, if one task throws an exception, all other tasks from the same group are cancelled".
  • Weight#count (Weight.java:196): "@throws IOException if there is a low-level I/O error".
  • ExitableDirectoryReader (ExitableDirectoryReader.java:32-37): "If QueryTimeout#shouldExit() returns true, an ExitingReaderException is thrown."
  • CollectorManager (CollectorManager.java:35-39): several leaf collectors may be requested for the same leaf, and "Any computation or logic that needs to happen once per segment requires specific handling in the collector manager implementation".
  • Dead catch. TotalHitCountCollectorManager.java:138-139 catches ExecutionException from get(); that can only happen if someone calls completeExceptionally, and no code path does.
  • Documented feature. The slices javadoc and the 10.0.0 CHANGES entry ("GITHUB#13542: Add initial support for intra-segment concurrency") present partitions as supported; the only documented limitation is performance.
  • Tests and history. No upstream test throws from Weight#count with partitions (TestTotalHitCountCollector has one test; TestExitableDirectoryReader searches without an executor). The #13542 threads discuss double counting, the query cache and clearing the map, never a failing first partition. 9.x and the parent of #13542 propagate the exception across concurrent slices (2 of 2 pass).
The devil's-advocate check167 words

Strongest case for "intended" (built by the cross-model reviewer and the tie-break adjudication): partitions are opt-in and much of their path is not stable API (the executor constructors, LeafSlice, LeafReaderContextPartition, Collector, CollectorManager and TaskExecutor are @lucene.experimental, slices is Expert, and the test's ConstantScoreWeight is @lucene.internal); TaskExecutor deliberately waits for started tasks so that no task outlives invokeAll; SECURITY.md line 28 makes bounding untrusted input the application's job.

Why it fails: @lucene.experimental marks API instability, not permission to hang, and count and the manager are not tagged. Waiting for started tasks is safe only if they can finish; here the manager's own coordination future is never completed once its producer throws, which is the manager's defect, not the executor's. The whole-segment path and the dead ExecutionException catch show the intended outcome. The reviewer's "not real" was procedural, for a read-only session that could run nothing; on substance it agreed with the root cause and rated the intended-behaviour argument weak. Verdict: a defect.

Suggested fix

Recommended A: complete the future exceptionally when the first partition fails

Complete the shared future exceptionally when the first partition fails: one more catch in LeafPartitionAwareTotalHitCountCollector#getLeafCollector (+5 lines). The test passes with assertions on and off, and so do 134 tests in 14 classes (the test, the hook-free test and 12 closest core suites), 67 facet tests and the core static checks. Fix C also covers manager reuse.

} catch (CollectionTerminatedException e) {
  firstEarlyTerminated.complete(true);
  throw e;
} catch (Throwable t) {
  // e.g. Weight#count threw: release the other partitions of this leaf, which wait on
  // this future, so that the search fails with t instead of hanging
  firstEarlyTerminated.completeExceptionally(t);
  throw t;
}
All options and trade-offs4 options · 936 words

Two options were built and validated in a scratch worktree at ea57360912ed (removed afterwards): A, the minimal fix, recommended for this issue, and C, which also fixes the related reuse bug LFV-053 and surfaced the original exception at top level in every recorded trial. B and D are untested.

A: complete the future exceptionally when the first partition fails (validated by the checks) (recommended)

In LeafPartitionAwareTotalHitCountCollector#getLeafCollector, after the CollectionTerminatedException catch, catch any Throwable, call firstEarlyTerminated.completeExceptionally(t) and rethrow. Every waiter's get() then returns, every started task finishes, and invokeAll rethrows. +5 lines, TotalHitCountCollectorManager.java only; no Java 22 syntax, so it backports to 10.x as is.

} catch (CollectionTerminatedException e) {
  firstEarlyTerminated.complete(true);
  throw e;
} catch (Throwable t) {
  // e.g. Weight#count threw: release the other partitions of this leaf, which wait on
  // this future, so that the search fails with t instead of hanging
  firstEarlyTerminated.completeExceptionally(t);
  throw t;
}

Trade-offs

  • Wrapped exception, sometimes. A waiter rethrows new RuntimeException(ExecutionException(cause)) (the existing lines 138-139). When the waiter's task comes first in the result list, the caller sees that wrapper, with the original as its cause and as a suppressed exception: 2 of 200 IOException calls in this doc's run, 7 of 400 in the tie-break, 8 of 200 in the study. Code that catches ExitingReaderException misses those.
  • Reuse is not fixed (LFV-053): a failed search's entries stay in the map because reduce() never runs, so a reused manager fails with a spurious wrapped exception instead of hanging. IndexSearcher#count and FacetsCollectorManager build a new manager per call, so for them A ends the hang and the reuse problem does not arise (the wrapped-exception caveat still applies).
  • No cost on the success path; no benchmark was run.

What the checks validated

  • Stock: the test fails 2 of 3 through gradle, with assertions on and with -Ptests.asserts=false.
  • Patched, gradle, assertions on and off: the new test, the hook-free test, TestTotalHitCountCollector, TestIndexSearcher, TestTaskExecutor, TestExitableDirectoryReader, TestMultiCollectorManager, TestMultiCollector, TestCollectorManager, TestSearchWithThreads, TestTermQuery, TestBooleanQuery, TestLRUQueryCache, TestTotalHits: 14 classes, 134 tests, 0 failures, 5 skipped. Most use newSearcher, which picks partitions at random, so they can exercise the changed collector's success path (no coverage was measured).
  • Patched, facet suites that call FacetsCollectorManager.search with n == 0: 6 classes, 67 tests, 0 failures.
  • :lucene:core:check -x test passes with the fix and the test (google-java-format, ecjLint, forbiddenApis).
  • Standalone harness: the test 3 of 3 (-ea, -da); the hook-free test 0 hangs in 200 calls per test; the study's 7-test class 6 of 7, the failing one being LFV-053's reuse test.

C: A, plus decisions scoped to one search, plus a waiter that skips instead of rethrowing (validated by the checks)

The fix the 2026-10-22 study model-checked: key the map by the search's Weight first (Map<Weight, Map<Object, Future<Boolean>>>, chosen in setWeight, which IndexSearcher calls before any leaf), complete the future exceptionally as in A, and let a waiter whose first partition failed throw CollectionTerminatedException, so in the tested paths the search fails with the original exception. +23/-6 lines, same file. Also fixes LFV-053.

private final Map<Weight, Map<Object, Future<Boolean>>> earlyTerminatedMap = new ConcurrentHashMap<>();

@Override
public void setWeight(Weight weight) {   // in LeafPartitionAwareTotalHitCountCollector
  super.setWeight(weight);
  earlyTerminatedMap = perWeight.computeIfAbsent(weight, _ -> new ConcurrentHashMap<>());
}
// first partition: catch (Throwable t) { firstEarlyTerminated.completeExceptionally(t); throw t; }
// waiter: catch (ExecutionException _) { throw new CollectionTerminatedException(); }

Trade-offs

  • Larger, and changes the map's lifetime (LFV-053's fix): a reused manager keeps a failed search's per-Weight map until the next successful reduce() clears the whole map.
  • The skipping waiter relies on the first partition's exception failing the search, which holds in IndexSearcher; an IndexSearcher subclass whose searchLeaf swallowed it would undercount instead of failing.
  • getLeafCollector without a prior setWeight would hit a null map (IndexSearcher always calls setWeight, IndexSearcher.java:811). Uses _ (Java 22+): a 10.x backport needs named variables. The map is keyed by the Weight object, not by a per-search token, and one manager shared by concurrent searches was not tested; TaskExecutor still reports whichever task exception comes first in its list.

What the checks validated

  • The study's TLC model of this protocol passes every count-barrier configuration, with throwing counts and manager reuse.
  • Gradle: the same 14 core classes, 134 tests, 0 failures, assertions on and off; facets 67 tests, 0 failures; :lucene:core:check -x test passes (a first try failed ecjLint on an unused catch variable, now _).
  • Harness: the test 3 of 3 (-ea, -da); the hook-free test 0 hangs in 200 calls per test, the original exception at top level in 200 of 200 per partitioned trigger; the study's 7-test class 7 of 7, reuse test included.

B: A plus the skipping waiter, without per-search scoping (not recommended)

Removes the wrapped exception with a 2-line change to the waiter.

Trade-offs

On a reused manager, a stale, exceptionally completed entry would make every partition of that segment skip silently, turning LFV-053's hang into a silent undercount. Only safe with C's scoping. Untested.

D: decide per segment without one task blocking on another (redesign, untested)

Make the "count from statistics or collect" decision without a blocking barrier, for example before the tasks fork, or deterministically in each partition.

Trade-offs

Removes the class of bug, but the barrier exists so that the query cache cannot answer some partitions with a whole-segment count, and getLeafCollector is the first point where leaf, weight and collector meet. A TaskExecutor-level change such as the stale PR #12756 (stop running siblings when one throws) would not release a thread parked in CompletableFuture.get().

Downstream note

Downstream: on a service that enables intra-segment partitions and bounds queries with an ExitableDirectoryReader timeout, each such hang pins its calling thread and, in the usual order, a pool thread for good, so repeated timeouts can use up the search pool. Not security-sensitive: out of scope of SECURITY.md (strict re-evaluation, 2026-10-27).

How it was found

TLA+ model checking. A model of count with partitions on top of TaskExecutor, checked with TLC, deadlocked in a 6-state trace once Weight#count was allowed to throw. The trace became a JUnit test on the stock jars, which an independent verifier reproduced with its own test.

The method in full207 words

Method: formal (TLA+ model checking with TLC).

  • The 2026-10-22 formal-verification study wrote four design-level TLA+ models of Lucene's search-side concurrency, each with a file:line mapping table to the Java code. CountBarrier.tla models TotalHitCountCollectorManager with intra-segment partitions on top of TaskExecutor: the shared map, registration, the count outcomes (count, no count, throw), the blocked waiter, submission, the caller claiming tasks, cancellation of unstarted tasks and result collection.
  • With throwing counts allowed, TLC reported a deadlock in a 6-state trace (one segment, two partitions, one worker): the worker's partition registers the future, its count throws, the caller's partition waits forever. Larger configurations (3 slices, 2 workers; 3 segments, 2 searches) deadlock the same way. The same model without the throw is deadlock-free and returns the right count.
  • The trace was translated into a deterministic JUnit test on the stock jars; the study's independent verifier wrote its own 7-test reproduction (4 fail, both interleavings, the stock timeout variant, the reuse variant) and classified it as a new bug.
  • On 2026-10-26 the hunt re-ran it on ea57360912ed, an independent reviewer from another model family (OpenAI GPT-5.6) tried to refute it in a read-only session, a tie-break judge wrote a hook-free test, and the history, prior-report and security checks followed.

Prior reports, history and checks

Prior reports

No prior report found: over 150 queries across GitHub issues and PRs, downstream repositories, mailing lists, the JIRA archive and the web, #13542's 129 review and issue comments, and the file lists of all 411 open PRs. Closest: #12278, a general discussion of failed concurrent searches leaving threads behind.

Searches6 searches

The searches found no issue, PR, commit, list thread or JIRA entry describing this hang, and no open PR fixing it. Only open PR #16847 (long global doc ids) edits the class, without touching the failure path. GitHub search does not index review comments; the saved #13542 comments cover that PR.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs, apache/lucene2026-10-2672 GraphQL queries (class and field names, completeExceptionally, allowSegmentPartitions; hang, deadlock, never returns, stuck, timeout) plus a sweep of every item since 2024-09-01 matching themno report; related: #12278, #12756, #16112, #13745, #12523, #12689, #13472
Threads read in full2026-10-26about 20 issues and PRs including #13542 (129 saved comments), two dev@ design threadsno comment on a failing first partition
Open PRs2026-10-26file lists of all 411 open apache/lucene PRsonly #16847 touches the class (Integer to Long), not a fix
Downstream and GitHub-wide2026-10-2618 GraphQL queries, 2 OpenSearch code searchesno hang report; OpenSearch uses its own count manager
Mailing lists, JIRA archive, web2026-10-2636 list queries (dev@, java-user@, issues@, commits@), 5 JIRA-archive queries, 7 web searches, a reduced 14-query GitHub passnothing relevant
2026-10-22 study2026-10-22about 25 GitHub keyword queries, the #13542 review threads, a grep of 4,898 issues and PRs, a web searchno report

History

Introduced by #13542 (merged 2024-09-10), the change that added intra-segment partitions; first released in 10.0.0. All 12 releases 10.0.0 to 10.5.2, branch_10_6 and main hang; 9.x has no partitions. Not fixed upstream.

Versions tested10 rows

Introduced by #13542, "Add support for intra-segment search concurrency" (javanna), merged 2024-09-10 as fafd6af004e on main, before the 10.0 branch point. It added the partition-aware collector; git blame gives lines 108-145 of TotalHitCountCollectorManager.java to it, and the file is byte-identical (blob ac77fc96595) in every 10.x release, branch_10_6 and main. The TaskExecutor behaviour that turns a stuck task into a stuck caller predates it (#12523, #12689, #13472). Release and commit jars were run with the same test; commit-level runs used plain javac builds of core, codecs and test-framework, not full gradle builds.

Versions and branches executed, with results
Version or commitVerdictResult
9.12.0, 9.12.3 no partitionsthe test does not compile (no partition API); a two-segment analog propagates the exception, 2 of 2 pass
942065cc3e7 (parent of #13542) commit levelno partition API; the inter-segment analog passes 2 of 2
fafd6af004e (#13542) introduced2 of 3 fail; 50 of 50 hook-free calls hang; with only the minimal fix shadowed, 3 of 3 pass
10.0.0 first affected2 of 3 fail with -ea and -da; hook-free 48 of 50 hang (history run) and 12 of 16 did not return (tie-break; only 1 auto-confirmed, because 10.0.0 names the wait frame differently); minimal fix 3 of 3 pass
10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 affected2 of 3 fail in each
10.5.2 affected2 of 3 fail with -ea and -da; hook-free 11 of 16 (tie-break) and 44 of 50 (study) hang
branch_10_6 at 2763db11b0a (before 10.6.0 RC1) affected2 of 3 fail
main ea57360912ed affected2 of 3 fail through gradle and the harness, -ea and -da; hook-free 12 of 16 (-da), 15 of 16 (-ea)
main + fix A not affected3 of 3 pass; 134 core and 67 facet tests pass; core static checks pass
main + fix C not affected3 of 3 pass; 134 core and 67 facet tests pass; the study's 7 of 7 pass

Independent checks

Four checks say real: an independent verifier, a current-main reproducer, a tie-break judge with its own hook-free test, and this doc's gradle and harness runs. A cross-model refute review (OpenAI GPT-5.6) voted not real only on procedure, as a read-only session that ran nothing, and agreed on the root cause.

Checks and verdicts289 words

Every check ran on main ea57360912ed (the study on 7cecf79523, an identical file) unless noted.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (2026-10-22) new bugOwn 7-test JUnit through public extension points only: 4 fail (both interleavings, the stock timeout, manager reuse), controls pass; hook-free 124 and 176 of 200 hang on main, 44 of 50 on 10.5.2; its minimal patch gives 0 of 200.
Current-main reproducer (2026-10-26) realThe latch test fails 2 of 3 with -ea and -da, 10 of 10 derived seeds per bug test; 10.5.2 and 10.0.0 fail the same; the study's class re-run: 4 of 7 fail, hook-free 121 of 200 hang.
Refute review (OpenAI GPT-5.6) not real, low confidence (procedural)A read-only session that could run nothing; it said its verdict was procedural, for "no independent test ran". On substance it agreed with the root cause and the in-contract trigger and rated the intended-behaviour argument weak. A separate run re-ran the test on 10.5.2 with -da for it: 2 of 3 fail.
Tie-break adjudication realRe-ran the latch test on main and wrote a hook-free test (only the slices opt-in): 12 and 15 of 16 hang on main (-da, -ea), 11 of 16 with an IOException, 11 and 12 of 16 on 10.5.2 and 10.0.0; controls 16 of 16; minimal fix 0 of 200. Confirmed the waiter-on-caller hang with the producer thread terminated.
This doc's runs reproduces; fixes A and C validatedStock: 2 of 3 fail through gradle (-ea, -da) and the harness on main, 10.5.2 and 10.0.0. Fix A and fix C: 134 core and 67 facet tests pass, core static checks pass; harness runs as in Suggested fix.

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 notes625 words

Finding record

LFV-045, snapshot of 2026-11-06

Registry title
IndexSearcher#count hangs forever when Weight#count throws, for example on a query timeout, on a segment split into intra-segment partitions
Area
search / concurrency
Priority
Medium, rank 19: Once opt-in intra-segment partitions are enabled with an executor, any count whose timeout fires or whose Weight#count throws hangs forever and parks executor threads, a denial of service that defeats the timeout in 10.0-10.5.2, but only direct Lucene users are exposed (Elasticsearch is not).
Impact class
hang
Impact note
Direct Lucene users who give IndexSearcher an executor and turn on the opt-in intra-segment partitions, whenever the first partition of a segment throws from Weight#count (a stock ExitableDirectoryReader timeout, an IOException, AlreadyClosedException); also FacetsCollectorManager.search(..., n == 0) and MultiCollectorManager wrappers; ordinary scale; released 10.0.0-10.5.2.
Summary
You search with an executor and intra-segment partitions (IndexSearcher.slices(..., allowSegmentPartitions=true)) and call IndexSearcher#count, and the first partition of a segment to arrive throws from Weight#count, for example the ExitingReaderException of a stock ExitableDirectoryReader timeout. You expect the exception to propagate, as TaskExecutor#invokeAll promises. Instead TotalHitCountCollectorManager completes the segment's shared future only on success or CollectionTerminatedException, so the other partitions wait in an unbounded get() and the call never returns (with no test hooks, 124/200 and 176/200 calls hung on main, 44/50 on 10.5.2).
Introduced
#13542 (2024-09-10)
First affected version
10.0.0 [read]
Affected versions
10.0.0-10.5.2, branch_10_6 and main (10.1.0, 10.5.2, main [exec])
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (gradle and the standalone harness, assertions on and off); the Maven Central jars of all 12 releases 10.0.0 to 10.5.2, and 9.12.0 and 9.12.3 (not affected); branch_10_6 at 2763db11b0a; the introducing commit fafd6af004e and its parent 942065cc3e7; fixes A and C in a scratch worktree at ea57360912ed (removed afterwards). Environment: OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • SECURITY.md:38, 50 (threat model)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:48-62, 75, 78-83, 108-144
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollector.java:46-54
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:70-135, 175-198
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:312-345, 398-416, 495-529, 778-788, 831-858
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:254-268
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:32-37, 950-952, 976-999
  • Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/FacetsCollectorManager.java:269-274
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84, 811; LRUQueryCache.java:892-927
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:194-197, 226-231, 323-325, 402
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:102-129
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:71-75, 181-198
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/Weight.java:181-198
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:32-37
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CollectorManager.java:35-39
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:29-35, 130-140
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:317-321
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:199, 216, 313, 1000, 1058; Collector.java:44; CollectorManager.java:42; TaskExecutor.java:45; ConstantScoreWeight.java:27
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:64-74, 86-93, 138-139

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

Repro files

About the diagrams

The summary card (bug.png) shows the setup, what happens inside on the two threads, the fix and the affected versions. It follows the hook-free setup (one 2,000-document segment, two partitions, a 1-thread pool, QueryTimeoutImpl(0)); the outcomes shown come from a probe run on ea57360912ed.

The card's title states that case without its conditions: a timeout hangs count only when it is first detected inside the first partition's Weight#count and the other partition's task has already started (sections above).

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.