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.
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.- Index one segment of 2,000 documents, all
f:a. GiveIndexSearchera 1-thread pool and the documented opt-inslices(leaves, 1000, 1, true): the segment becomes two partitions,[0, 1000)and[1000, 2000). Wrap the reader inExitableDirectoryReaderwith a spent budget,new QueryTimeoutImpl(0). - Call
searcher.count(new TermQuery(new Term("f", "a"))). You expectExitingReaderException, as when the segment is not split: 16 of 16 such calls threw. - 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 inF.get(), with no timeout, for its answer. Weight#countthrows, but F is completed only on success orCollectionTerminatedException.TaskExecutorcancels only tasks that have not started and waits for the rest, socountnever returns: 12 of 16 calls hung on main (-da), each with the caller and a pool thread parked for good.- Same on every release 10.0.0 to 10.5.2. Completing F exceptionally when
Weight#countfails (5 lines) gives 0 hangs in 200 calls per test.
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.
- 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 staticslices(leaves, maxDocsPerSlice, maxSegmentsPerSlice, true), which splits any segment with more thanmaxDocsPerSlicedocuments into at most 5 partitions ofmaxDoc / ndocuments, the last one also taking the remainder. Weight#count(ctx): the per-segment shortcut thatcounttries first, for exampleTermQuery'sdocFreq. It is declared to throwIOException.- The count barrier: with partitions,
TotalHitCountCollectorManagerhands out aLeafPartitionAwareTotalHitCountCollector. 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.
IndexSearcher#countbuildsnew TotalHitCountCollectorManager(getSlices())(IndexSearcher.java:525), makes one task per slice and callsTaskExecutor#invokeAll(:778-788), which forks all tasks but one and runs tasks on the calling thread too (TaskExecutor.java:91-114).- Each task calls
collector.setWeight(weight)(IndexSearcher.java:811); its first call for a partition is thencollector.getLeafCollector(ctx)(:836). The first partition of the segment puts a new, incompleteCompletableFutureF into the shared map withputIfAbsent(TotalHitCountCollectorManager.java:110-115) and callsWeight#countthroughsuper.getLeafCollector(TotalHitCountCollector.java:48). - F is completed only on success (
complete(false),:120) or onCollectionTerminatedException(complete(true),:122-124). There is no other catch and nofinally. Every other partition of the segment waits inearlyTerminated.get()with no timeout (:131). - When
Weight#countthrows anything else, F stays incomplete. The failing task'ssetExceptioncancels only tasks that have not started;canceldoes nothing to a started task, by design (TaskExecutor.java:175-198).collectResultsthen waits with an untimedfuture.get()(:118-123). - So the caller parks in
collectResultswhile 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
ExecutortoIndexSearcherand overrideslices(List)withslices(..., true), and that callIndexSearcher#count,search(q, TotalHitCountCollectorManager), aMultiCollectorManagercontaining one, orFacetsCollectorManager.search(searcher, q, 0, fcm). - What triggers it. Any exception from the first-arriving partition's
Weight#count: a time budget enforced throughExitableDirectoryReader(or an interrupt it detects), anIOExceptionor a customWeight(these run), or, by reading, anAlreadyClosedExceptionwhen 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::runexecutor (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 againstshutdownNowon the search executor withNIOFSDirectory. 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.
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).
/*
* 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();
}
}
}
# 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 boundedgetseesExecutionException); actual: the other partition parks inTotalHitCountCollectorManager(line 131) and the 10 sgetseesTimeoutException.
More files, environment, full output45 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)
[...]TaskExecutor#invokeAllpromises the exception is rethrown, not a hang;ExitableDirectoryReaderpromises anExitingReaderExceptionon timeout.- The unsplit segment throws promptly, and the code already catches
ExecutionExceptionfrom the shared future, but nothing ever completes it exceptionally. CollectorManagermakes per-segment coordination the manager's job;countand 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-139catchesExecutionExceptionfromget(); that can only happen if someone callscompleteExceptionally, and no code path does. - Documented feature. The
slicesjavadoc 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#countwith partitions (TestTotalHitCountCollectorhas one test;TestExitableDirectoryReadersearches 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.
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 200IOExceptioncalls in this doc's run, 7 of 400 in the tie-break, 8 of 200 in the study. Code that catchesExitingReaderExceptionmisses 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#countandFacetsCollectorManagerbuild 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 usenewSearcher, 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.searchwithn == 0: 6 classes, 67 tests, 0 failures. :lucene:core:check -x testpasses 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-
Weightmap until the next successfulreduce()clears the whole map. - The skipping waiter relies on the first partition's exception failing the search, which holds in
IndexSearcher; anIndexSearchersubclass whosesearchLeafswallowed it would undercount instead of failing. getLeafCollectorwithout a priorsetWeightwould hit a null map (IndexSearcheralways callssetWeight,IndexSearcher.java:811). Uses_(Java 22+): a 10.x backport needs named variables. The map is keyed by theWeightobject, not by a per-search token, and one manager shared by concurrent searches was not tested;TaskExecutorstill 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 testpasses (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.
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.
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: 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).
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:linemapping table to the Java code.CountBarrier.tlamodelsTotalHitCountCollectorManagerwith intra-segment partitions on top ofTaskExecutor: 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs, apache/lucene | 2026-10-26 | 72 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 them | no report; related: #12278, #12756, #16112, #13745, #12523, #12689, #13472 |
| Threads read in full | 2026-10-26 | about 20 issues and PRs including #13542 (129 saved comments), two dev@ design threads | no comment on a failing first partition |
| Open PRs | 2026-10-26 | file lists of all 411 open apache/lucene PRs | only #16847 touches the class (Integer to Long), not a fix |
| Downstream and GitHub-wide | 2026-10-26 | 18 GraphQL queries, 2 OpenSearch code searches | no hang report; OpenSearch uses its own count manager |
| Mailing lists, JIRA archive, web | 2026-10-26 | 36 list queries (dev@, java-user@, issues@, commits@), 5 JIRA-archive queries, 7 web searches, a reduced 14-query GitHub pass | nothing relevant |
| 2026-10-22 study | 2026-10-22 | about 25 GitHub keyword queries, the #13542 review threads, a grep of 4,898 issues and PRs, a web search | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.0, 9.12.3 | no partitions | the test does not compile (no partition API); a two-segment analog propagates the exception, 2 of 2 pass |
942065cc3e7 (parent of #13542) | commit level | no partition API; the inter-segment analog passes 2 of 2 |
fafd6af004e (#13542) | introduced | 2 of 3 fail; 50 of 50 hook-free calls hang; with only the minimal fix shadowed, 3 of 3 pass |
| 10.0.0 | first affected | 2 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 | affected | 2 of 3 fail in each |
| 10.5.2 | affected | 2 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) | affected | 2 of 3 fail |
main ea57360912ed | affected | 2 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 affected | 3 of 3 pass; 134 core and 67 facet tests pass; core static checks pass |
| main + fix C | not affected | 3 of 3 pass; 134 core and 67 facet tests pass; the study's 7 of 7 pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (2026-10-22) | new bug | Own 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) | real | The 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 | real | Re-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 validated | Stock: 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. |
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 410 KB
Finding record, Lucene and upstream sources, repro files and diagram notes625 words
- 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_6at2763db11b0a; the introducing commitfafd6af004eand its parent942065cc3e7; fixes A and C in a scratch worktree atea57360912ed(removed afterwards). Environment: OpenJDK 25.0.3, Linux aarch64.
- 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-144Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollector.java:46-54Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:70-135, 175-198Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:312-345, 398-416, 495-529, 778-788, 831-858Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TermQuery.java:254-268Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:32-37, 950-952, 976-999Lucene main ea57360912ed: lucene/facet/src/java/org/apache/lucene/facet/FacetsCollectorManager.java:269-274Lucene 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, 402Lucene 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-198Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/Weight.java:181-198Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/ExitableDirectoryReader.java:32-37Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CollectorManager.java:35-39Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:29-35, 130-140Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:317-321Lucene 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
- TestIndexSearcherCountPartitionHang.java (JUnit test):
repro/LFV-045/src/org/apache/lucene/search/TestIndexSearcherCountPartitionHang.java(83 lines)
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).
assets/LFV-045/bug.png(410 KB)
