TotalHitCountCollectorManager remembers, per segment, how the first partition counted it, and forgets only in reduce(), at the end of a search that succeeds. A search that fails during collection never gets there, so its notes mislead the next search.
- Intra-segment concurrency (opt-in) splits one segment into partitions; different collectors, possibly on different threads, search them.
- A total-hit count can often be read for a whole segment from index statistics (
Weight#count), for example forMatchAllDocsQuery. - So the first partition of a segment decides for all of them: take the whole segment's count from statistics and tell the other partitions to skip, or count documents one by one and tell the others to do the same. The manager keeps that decision in a map keyed by the segment, shared by all its collectors.
weight.count(). Select the still to open it at full size. Generated with AI, checked against the code.- Index 2 documents in one segment, open an
IndexSearcherthat splits the segment into 2 partitions (slices(leaves, 1, 1, true)), and create oneTotalHitCountCollectorManagerthat you keep. - Search
MatchAllDocsQuerywithMultiCollectorManager(countManager, yours), where your collector throws incollect(). The search fails, as it should. Before it failed, the count collector had noted for the segment: "counted from index statistics, the other partitions must skip". - Search
MatchAllDocsQueryagain with the samecountManager. You expect 2. - You get 0, with no error. The note is cleared only in
reduce(), which a search that fails during collection never reaches, so both partitions of the segment skip themselves. This search's ownreduce()clears the note, so a third search returns 2 again. - At larger scale: 1,000 documents in 4 partitions and a stock
ExitableDirectoryReadertimeout; the retried request reports count 0 next toTopFieldDocs.totalHits= 1000. If the failure was insideWeight#count, the next search hangs instead.
Applications that opt into intra-segment partitions, keep one TotalHitCountCollectorManager across searches (alone or inside MultiCollectorManager), and reuse it on the same reader right after a search with it failed: another collector threw, an ExitableDirectoryReader timeout, an IOException. One response gets a wrong total. IndexSearcher#count is not affected.
Mechanism, terms and numbers562 words
With intra-segment partitions, TotalHitCountCollectorManager lets the first partition of each segment decide whether the whole segment is counted from index statistics, and stores that decision in a map inside the manager, keyed by the segment only. The map is cleared only in reduce(), and a search that fails during collection never calls reduce(). So the next search with the same manager on the same reader finds the old decisions: every segment marked "already counted" contributes 0 hits, with no error (0 instead of 2 in the repro; count 0 next to TopFieldDocs.totalHits 1000 in a stock-components check). That search's own reduce() clears the map, so the one after it is right again. Every release from 10.0.0 to 10.6.0 and main is affected.
- The shared decision.
TotalHitCountCollectorManager.java:64-75: "if the first partition of a segment early terminates, count has been already retrieved for the entire segment hence subsequent partitions of the same segment should also early terminate without further incrementing hit count". The state is oneMap<Object, Future<Boolean>> earlyTerminatedMap(aConcurrentHashMap) per manager (:75), handed to every partition-aware collector (:77-83). - The key never changes.
getLeafCollectorlooks upearlyTerminatedMap.get(context.id())(:110).IndexReaderContext#id()is a per-context identity object (IndexReaderContext.java:41-44,:53-61), the same on every search of the same reader. Neither the query nor the search is part of the key. - The first partition decides.
TotalHitCountCollectorManager.java:111-125: the first partition of the segment to reachgetLeafCollector(the code's "first thread for a given leaf", not necessarily the one with the lowest doc ids) stores aCompletableFutureand callsTotalHitCountCollector#getLeafCollector, which adds the whole segment's count and throwsCollectionTerminatedExceptionwhenweight.count(context) != -1(TotalHitCountCollector.java:46-54); the manager completes the future withtrue(TotalHitCountCollectorManager.java:122-124), else withfalse(TotalHitCountCollectorManager.java:120). - The decision is stored before the search fails.
MultiCollector#getLeafCollectorasks every sub-collector before anycollect()(MultiCollector.java:123-143), so the count collector has storedtruebefore the application's collector throws. - Clean-up only on success.
reduce()is the only place the map is cleared (TotalHitCountCollectorManager.java:86-93, after the comment "Make the same collector manager instance reusable across multiple searches").IndexSearchercallsreduceaftertaskExecutor.invokeAll(...)(IndexSearcher.java:788-789), and when a task throws,invokeAllrethrows (TaskExecutor.java:118-135), so a search that fails during collection never gets there. - The next search reads the stale note. A stored
truemakes every partition of that segment throwCollectionTerminatedException(TotalHitCountCollectorManager.java:130-134), the first partition of the new search included.IndexSearcher#searchLeaftreats that as "there is no doc of interest in this reader context" (IndexSearcher.java:835-841). The segment adds 0. - Until a search completes. That search reaches
reduce(), which clears the map, so the following search is right. A search that also fails beforereduce()leaves stale entries for the next one. - The hang variant. If the first search failed inside the first partition's
Weight#count, the future is never completed and the next search waits forever inearlyTerminated.get()(TotalHitCountCollectorManager.java:131), even single-threaded. That pending future is LFV-045's root cause; the stale map is what carries it into the next search.
Not affected: IndexSearcher#count builds a new manager per call (IndexSearcher.java:525); the IndexSearcher#setTimeout timeout is caught in searchLeaf (:856-857), so reduce() still runs; without partitions the manager hands out plain, stateless collectors (TotalHitCountCollectorManager.java:79-82).
Exposure and likelihood271 words
- Opt-in. Partitions are off by default (
IndexSearcher.java:317-324); you enable them by overridingslicesand callingslices(leaves, maxDocs, maxSegments, true). Lucene's own randomized tests do this often (Concurrency.INTRA_SEGMENT,LuceneTestCaseParent.java:1144-1149). - Manager reuse. The code says reuse is supported (
TotalHitCountCollectorManager.java:87-89); most callers, includingIndexSearcher#count, still create a new manager per search. - The failure. Any exception during collection after a segment's first partition took its count from index statistics (
MatchAllDocsQuery, aTermQueryon a segment without deletions, ...): another collector in theMultiCollectorManager(the use the class javadoc names), anExitableDirectoryReadertimeout, anIOException. TheIndexSearcher#setTimeouttimeout does not count: it is caught andreduce()runs. - What you see. 0 hits for the affected segments, whatever the next query is, and nothing signals it. In the stock-components check the same response held count 0 and
TopFieldDocs.totalHits1000. With a real 4-thread pool, 200 of 200 rounds were wrong. If the failure was insideWeight#count, the next search hangs. - How long. Until a search with the manager completes: its
reduce()clears the map (normally the very next search). - Timeout reach (read, not executed). An application that wraps a new
ExitableDirectoryReaderper request gets new segment ids per request, so a stale entry never matches, even with a reused manager. The timeout trigger needs a reader wrapped once with a resettable timeout; the collector trigger has no such limit. - Workaround. Create a new
TotalHitCountCollectorManagerper search (a fresh manager after the failure gives the right count). - Likelihood: rare (registry): all of opt-in partitions, manager reuse and a failure just before on the same reader.
One JUnit method on the public and protected API: 2 empty documents in one segment, a searcher with a caller-runs executor whose slices override gives one partition per document, a failing MultiCollectorManager(countManager, failing) search, then the same countManager alone. Deterministic, single thread; fails the same way with assertions on and off.
package org.apache.lucene.search;
import java.util.Collection;
import java.util.List;
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.index.LeafReaderContext;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTotalHitCountCollectorManagerReuse extends LuceneTestCase {
public void testReuseAfterFailedSearch() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
w.addDocument(new Document());
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher =
new IndexSearcher(reader, Runnable::run) {
@Override
protected LeafSlice[] slices(List<LeafReaderContext> leaves) {
return slices(leaves, 1, 1, true); // one partition per document
}
};
TotalHitCountCollectorManager countManager =
new TotalHitCountCollectorManager(searcher.getSlices());
CollectorManager<Collector, Void> failing =
new CollectorManager<>() {
@Override
public Collector newCollector() {
return new SimpleCollector() {
@Override
public void collect(int doc) {
throw new IllegalStateException("application collector failure");
}
@Override
public ScoreMode scoreMode() {
return ScoreMode.COMPLETE_NO_SCORES;
}
};
}
@Override
public Void reduce(Collection<Collector> collectors) {
return null;
}
};
expectThrows(
IllegalStateException.class,
() ->
searcher.search(
new MatchAllDocsQuery(), new MultiCollectorManager(countManager, failing)));
assertEquals(
"count of the next search with the same manager",
2,
(int) searcher.search(new MatchAllDocsQuery(), countManager));
}
}
}
}
cp $REPRO/TestTotalHitCountCollectorManagerReuse.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestTotalHitCountCollectorManagerReuse -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
- Expected
- The second search with the same manager returns 2, the number of matching documents.
- Observed
- It returns 0 with no error:
count of the next search with the same manager expected:<2> but was:<0>.
More files, environment, full output235 words
The test sits in org.apache.lucene.search, as Lucene's own tests do, but uses no package-private member: IndexSearcher(IndexReader, Executor), an override of the protected slices(List) calling the public static slices(leaves, 1, 1, true), getSlices(), TotalHitCountCollectorManager(LeafSlice[]), MultiCollectorManager, SimpleCollector, MatchAllDocsQuery. The executor Runnable::run runs everything on the caller in a fixed order. The test uses no randomness; the framework's randomized defaults (codec, query cache) do not change the result: seeds DEADBEEF, C0FFEE, 5EED1234 and 20 derived seeds all fail.
It compiles unchanged against 10.0.0 to 10.6.0 and main; 9.x has no partition API. Add the Apache license header before using it in a PR. The hang variant is not part of this test: it needs a failure inside Weight#count and is LFV-045's cause.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on every release from 10.0.0 to 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestTotalHitCountCollectorManagerReuse.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestTotalHitCountCollectorManagerReuse -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
TestTotalHitCountCollectorManagerReuse > testReuseAfterFailedSearch FAILED
java.lang.AssertionError: count of the next search with the same manager expected:<2> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:F5FBB92F36B7FFED]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.search.TestTotalHitCountCollectorManagerReuse.testReuseAfterFailedSearch(TestTotalHitCountCollectorManagerReuse.java:59)
# the same with -Ptests.asserts=false, on all 13 releases 10.0.0-10.6.0, and with 20 of 20 derived seeds (assertions off)- Reuse is designed in ("Make the same collector manager instance reusable across multiple searches",
TotalHitCountCollectorManager.java:87-89), and Lucene's own test reuses one manager. - Managers that must not be reused say so (
TopScoreDocCollectorManager.java:26); this one does not. CollectorManagercallsreduceonly after full collection (CollectorManager.java:30-32), so a reset inreduce()misses every failed search, and the result is a silent wrong number.
The full argument269 words
- Reuse is designed in.
TotalHitCountCollectorManager.java:87-89: "Make the same collector manager instance reusable across multiple searches. It isn't a strict requirement but it is generally supported as collector managers normally don't hold state, as opposed to collectors." - Upstream code reuses a manager.
TestTotalHitCountCollector#testBasicsruns two searches with one manager on anewSearcherwith a random concurrency, which may be intra-segment (TestTotalHitCountCollector.java:44-57).MustNotBooleanQueryBenchmarkkeeps one manager in a field and reuses it in every benchmark call (:82,:116,:175-178). - The design decision. In the #13542 review the author, javanna, wrote (2024-07-22): "Because the collector manager holds state, either we clean it in-between searches (reduce is the current place where that can be done, but that may change over time), or we declare that collector managers cannot be re-used across multiple searches (potential breaking change)", and chose
reduce. No review comment discusses a search that fails beforereduce(). In #15795 (2026) they wrote "Collectors managers may be reused across multiple executions". - Where reuse is not allowed, Lucene says so.
TopScoreDocCollectorManager.java:26andTopFieldCollectorManager.java:27: "Note that a new collectorManager should be created for each search due to its internal states." Nothing inCollectorManager,IndexSearcher#search(Query, CollectorManager),MultiCollectorManager, CHANGES.txt or MIGRATE.md says a manager becomes unusable after a failed search; MIGRATE.md presents this class as the example of per-segment handling (MIGRATE.md:1668-1676). - The interface implies the gap.
CollectorManager.java:30-32: reduce "is only called after all leaves have been fully collected". - The failure is ordinary. Another collector's exception or a timeout propagating out of a search is normal; the manager did nothing wrong.
The devil's-advocate check167 words
"CollectorManager promises nothing about reuse, the code comment says reuse 'isn't a strict requirement', and nothing promises reuse after a failed search: this is misuse, and a javadoc note is the answer." This was the cross-model refute review's only substantive objection, rated moderate.
Our answer: it is a reasonable reading of the API lifecycle (the cross-model fact check also rates it moderate), and successful reuse alone does not prove a guarantee after a failure. But reuse is designed in and exercised upstream (the comment, the test, the benchmark, the #13542 choice not to forbid reuse); the managers that must not be reused carry a per-search note and this one does not; CollectorManager itself makes a reduce-only reset miss failed searches; and the result is a plausible wrong number, so a caller cannot tell it should drop the manager. What it gets right: no javadoc promises reuse after a failure in so many words, so a documentation answer is possible; it would leave the minimal test failing.
Recommended Scope the decisions to one search (key the map by the search's Weight)
Scope the per-segment decisions to one search: key the manager's map by the search's Weight (which IndexSearcher passes to every slice collector through setWeight), then by the segment, so a failed search's notes never reach the next one. One file, +14/-4. The test and the 12 closest core suites pass (128 tests, assertions on and off); lint passes.
// Keyed by the search's Weight first, so that the decisions of a failed search (for which
// reduce() is never called) cannot leak into the next search that reuses this manager.
private final Map<Weight, Map<Object, Future<Boolean>>> earlyTerminatedMap =
new ConcurrentHashMap<>();
// LeafPartitionAwareTotalHitCountCollector
private final Map<Weight, Map<Object, Future<Boolean>>> perSearch;
private Map<Object, Future<Boolean>> earlyTerminatedMap;
@Override
public void setWeight(Weight weight) {
super.setWeight(weight);
earlyTerminatedMap = perSearch.computeIfAbsent(weight, _ -> new ConcurrentHashMap<>());
}All options and trade-offs4 options · 805 words
Two code options were built and run in a scratch worktree of 645d4fe14a20. LFV-045 (the hang inside one search when Weight#count throws) shares the class; its minimal fix does not cure this bug, and the combined patch below fixes both.
Scope the decisions to one search (key the map by the search's Weight) (validated by the checks) (recommended)
The map becomes Map<Weight, Map<Object, Future<Boolean>>>; each partition-aware collector picks its search's inner map in setWeight, which IndexSearcher calls before any leaf (IndexSearcher.java:808-815; MultiCollector forwards it, MultiCollector.java:161-163). getLeafCollector is unchanged and reduce() still clears the outer map. These are the same scoping hunks as LFV-045's option C (only the names differ), the per-weight fix the study model-checked.
// Keyed by the search's Weight first, so that the decisions of a failed search (for which
// reduce() is never called) cannot leak into the next search that reuses this manager.
private final Map<Weight, Map<Object, Future<Boolean>>> earlyTerminatedMap =
new ConcurrentHashMap<>();
// LeafPartitionAwareTotalHitCountCollector
private final Map<Weight, Map<Object, Future<Boolean>>> perSearch;
private Map<Object, Future<Boolean>> earlyTerminatedMap;
@Override
public void setWeight(Weight weight) {
super.setWeight(weight);
earlyTerminatedMap = perSearch.computeIfAbsent(weight, _ -> new ConcurrentHashMap<>());
}Trade-offs
- Lingering entries. A failed search's inner map stays until the next successful
reduce()clears the whole map: one inner map per failed search, at most one entry per segment, and its key keeps that search'sWeight(and what it references, such as the query) reachable. Repeated failures with no successful search in between add one each. - The key is the
Weightobject. Two searches that share oneWeightand one manager would share decisions (IndexSearcher#search(Query, CollectorManager)creates a newWeightper call). The map compares keys withequals/hashCode, whichWeightdoes not override, so Lucene's own weights are told apart by identity; a customWeightthat overrides them could make two searches share decisions (not tested; a key compared by identity would avoid it). One manager used by concurrent searches is not made safe (it is not today either: one search'sreduce()clears the map while another runs); not tested. - Needs
setWeight. AgetLeafCollectorcall without a priorsetWeightworks today (TotalHitCountCollectortreats a null weight as "no count shortcut",TotalHitCountCollector.java:48) but would hit a null map with this fix;IndexSearcheralways calls it (IndexSearcher.java:811). - Java 22
_. Fine on main; a 10.x backport needs a named lambda parameter. - Does not fix LFV-045 (the hang within one search); see the combined patch below. No benchmark was run; the change touches only the partitioned path.
What the checks validated
- Gradle in a scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF: stock, the test fails with assertions on and off; with the fix it passes, on and off. - Closest suites (the test,
TestTotalHitCountCollector,TestIndexSearcher,TestTaskExecutor,TestMultiCollectorManager,TestMultiCollector,TestCollectorManager,TestSearchWithThreads,TestTermQuery,TestBooleanQuery,TestLRUQueryCache,TestTotalHits,TestExitableDirectoryReader): 13 classes, 128 tests, 0 failures, 5 skipped, assertions on and off. Most usenewSearcher, which picks intra-segment partitions at random; no coverage was measured. checkGoogleJavaFormat,ecjLintandforbiddenApis(main and test) pass with the fix and the test.- An independent fact check re-ran the gradle runs on a clean copy of
645d4fe14a20(same results) and ran this fix through the tie-break's independent 1,000-document program: all 9 checks right, including the 4-thread pool and theExitableDirectoryReadertimeout path. - Not run: the whole core module, a benchmark.
The per-search scoping plus LFV-045's fix, as one patch (LFV-045's option C) (validated by the checks)
Adds completeExceptionally when the first partition's getLeafCollector throws anything but CollectionTerminatedException, and lets a waiter whose first partition failed skip its partition instead of throwing a wrapped copy (+23/-6). Fixes both bugs.
Trade-offs
One PR for two issues; the quiet-waiter part needs its own justification (it relies on the first partition's exception failing the search, which holds in IndexSearcher).
What the checks validated
Gradle, assertions on: this test, LFV-045's minimal test and the same closest suites, 14 classes, 129 tests, 0 failures, 5 skipped. The diff is byte-identical to LFV-045's validated option C.
completeExceptionally only, with or without removing the entry, ends the hang but keeps the stale true decisions.
Trade-offs
This test still returns 0 instead of 2, and the stock-components check still returns 0 instead of 1000 (executed). TLA+ predicted it before code was written: cb_F_fixrm still violates CountCorrect; only the per-search scoping (cb_F_fixpw) passes.
Add the note TopScoreDocCollectorManager carries.
Trade-offs
No code change, but it contradicts the reuse comment, the upstream test and benchmark, and the #13542 choice not to make reuse a breaking change; callers that reuse a manager keep getting a silent wrong count after any failure, and the minimal test keeps failing.
Search servers that keep one count manager across requests on a partitioned searcher can avoid this by creating a new TotalHitCountCollectorManager per search.
TLA+ model checking (TLC) of the per-segment count coordination in TotalHitCountCollectorManager, with manager reuse and failing searches modelled: one configuration broke the invariant "the count is correct" in a 31-state trace. The modeller turned it into a JUnit on the real code, and an independent verifier reproduced it with its own test.
The method in full180 words
Method: TLA+ model checking (TLC) of the count barrier (CountBarrier.tla, configuration cb_F, invariant CountCorrect, 31 states, 2026-10-22). In the trace, search 1 decides true (counted from index statistics) for the partitioned segment, then fails while scoring another segment, so no future is left pending; search 2 with the same manager sees the stale true, skips every partition of the segment and returns a short count. The same model showed before any code was written that completeExceptionally plus removing the entry is not enough (cb_F_fixrm) and that scoping the decisions to one search is (cb_F_fixpw). A separate configuration (cb_B, 12 states) gives the hang variant.
The modeller wrote JUnit tests on the real code (3 instead of 13 with two segments). An independent verifier, who read the claim but wrote their own test, reproduced it on main, 10.1.0 and 10.5.2 (0 instead of 13, plus the timeout and hang variants, 500 of 500 wrong on a 4-thread pool). The minimal test here is a one-method reduction of that verifier's test.
No prior report found (2026-10-31): GitHub issues and PRs, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, the web, and the files of all 409 open PRs. Closest: the #13542 review comment that chose to clear the state in reduce().
Searches4 searches
Related, not reports of this bug: the #13542 review comment (javanna, 2024-07-22) that chose to clear the state in reduce() rather than forbid reuse; a #15795 review comment (2026-05-20) that managers "may be reused across multiple executions"; #13745 (open, per-segment work across partitions); #12278 (open, exception handling in concurrent search, predates partitions); open PR #16847 (the only open PR on the file: Integer to Long, keeps the reduce-only clean-up); stale PR #12756 (stops sibling tasks, does not touch the map). In the registry, LFV-045 is the hunt's own sibling bug in the same method.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-31 | 109 GraphQL and 21 REST queries over class names, identifiers (earlyTerminatedMap, LeafPartitionAwareTotalHitCountCollector), reuse and failure phrasings and symptoms (wrong count, count 0, hang, stale) | no report; the related items above |
| Downstream repositories, all of GitHub, code search | 2026-10-31 | 15 GraphQL queries and 3 code searches | no report; no copy of the per-manager map outside Lucene and verbatim ports |
| Mailing lists, JIRA archive, web | 2026-10-31 | 60 list queries (dev@, java-user@, issues@, commits@), 10 JIRA and 6 web queries; the dev@ thread on intra-segment concurrency read in full | no report |
| Open apache/lucene PRs | 2026-10-31 | changed files of all 409 open PRs; diffs of those touching IndexSearcher's invokeAll/reduce path read | only #16847 edits the class, without changing the clean-up |
Introduced by #13542 (fafd6af004e0, javanna, 2024-09-10), which added intra-segment concurrency and this map; first released in 10.0.0. All 13 releases from 10.0.0 to 10.6.0 and main fail; 9.x has no partition API and counts right.
Versions tested4 rows
fafd6af004e0 added the map, the reuse comment with the clear() in reduce(), the partition-aware collector and the partition API itself; every other line of the class comes from #622 (2022), whose manager kept no state, and no other commit touched the file. TotalHitCountCollectorManager.java is the same blob (ac77fc96595e) at 10.0.0 to 10.6.0, main 645d4fe14a20, upstream main 2f424fd428da, branch_10_6 and branch_11x.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and standalone, assertions on and off; 20 of 20 derived seeds) |
| 10.0.0 to 10.6.0 (13 releases) | affected | the minimal test fails on every one, expected:<2> but was:<0> (10.0.0, 10.5.2, 10.6.0 also with assertions off) |
| 9.12.0, 9.12.3 | no partition API | the test does not compile; the same steps with the 9.x manager count 2 |
main 645d4fe14a20 with the fix | not affected | the minimal test and the closest core suites pass, assertions on and off |
An independent study verifier, a current-main reproducer, a tie-break with an independent lucene-core-only program and the version history all confirm it; the cross-model refute review could not run code and agreed on the root cause.
Checks and verdicts317 words
Every check except the cross-model review re-ran a repro on stock jars.
| Check | Verdict | What it found |
|---|---|---|
| study verifier (independent test) | new bug | Own 4-test class: control passes; 0 instead of 13 after a collector failure and after an ExitableDirectoryReader timeout (count 0 next to totalHits 13); the hang variant; 500 of 500 wrong on a 4-thread pool; main, 10.1.0 and 10.5.2. |
| current-main reproducer | real | Minimal test fails on main (on and off, 20 of 20 seeds off), 10.5.2 (on and off), 10.0.0 and 10.6.0; the per-search fix passes it, LFV-045's minimal fix does not. |
| cross-model refute review (OpenAI GPT-5.6, reading only) | not real, low (procedural) | Could not run code, so it gave not real; it agreed on the root cause and that the inputs are in contract; its one substantive objection (reuse after a failure is not promised) is rated moderate and answered under "Why it is a bug". |
| Second adjudication | real | Minimal test on main and 10.5.2 (on and off) and 10 of 10 from a new seed; an independent lucene-core-only program (1,000 docs, 4 partitions): 0 instead of 1000, 200 of 200 rounds on a real pool, count 0 next to totalHits 1000 with a stock timeout, on main, 10.5.2 and 10.0.0; controls right. |
| history | affected 10.0.0-10.6.0 | Minimal test on all 13 10.x releases and main; 9.12.0 and 9.12.3 not affected; no-partition controls count 2. |
| this doc | real | Gradle on stock main (on and off) and with fixes A and B in a scratch worktree. |
| fact checks of this doc (independent; cross-model, reading only) | holds | Re-ran the gradle command (stock fails; with the fix it passes, assertions on and off), the standalone runner on main, 10.0.0, 10.3.1 and 10.6.0, and the fix in the independent program (9 of 9 right). The wording errors both checks found are fixed in this version. |
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 593 KB
Finding record, Lucene and upstream sources, repro files and diagram notes541 words
- Registry title
- Reusing a TotalHitCountCollectorManager after a failed search silently returns count 0 or hangs
- Area
- search / concurrency
- Priority
- Low, rank 729: Moved down from Medium: reusing a TotalHitCountCollectorManager right after a failed intra-segment search silently returns 0 or hangs, the same code as LFV-045 but it also needs manager reuse right after a failure, and LFV-045's full fix covers it.
- Impact class
- silent wrong results
- Impact note
- Applications that use opt-in intra-segment partitions and reuse one TotalHitCountCollectorManager after a failed search on the same reader; released (10.1.0+ executed).
- Summary
- With intra-segment partitions, you reuse one TotalHitCountCollectorManager for a new search after an earlier search on the same reader failed. You expect the correct count. Instead its earlyTerminatedMap is only cleared in reduce(), so the count comes back as 0 (13 expected in the repro) or the search hangs.
- Introduced
- #13542 (fafd6af004e0) [read] (2024-09-10)
- First affected version
- 10.0.0 [read: git tag --contains]
- Affected versions
- 10.0.0+ [read]; 10.1.0, 10.5.2 and 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
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner (20 of 20 derived seeds with assertions off), and passes with the recommended fix (with the closest core suites, assertions on and off). Also fails on all 13 release jars from 10.0.0 to 10.6.0 (10.0.0, 10.5.2 and 10.6.0 also with assertions off); 9.12.0 and 9.12.3 lack the partition API, and the same steps there give the right count. Upstream main2f424fd428da(2026-10-30) has the sameTotalHitCountCollectorManager.javablob [read].
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:29-35, :64-75, :77-83, :86-93, :108-144a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollector.java:46-54a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:525, :788-789, :808-815, :835-841, :856-857a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:70-81, :118-135a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:123-143a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexReaderContext.java:41-44, :53-61
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:317-324, :525, :856-857a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:1144-1149, :2595-2599
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollectorManager.java:87-89a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CollectorManager.java:23-55a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:26; TopFieldCollectorManager.java:27a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestTotalHitCountCollector.java:44-57a Lucene main checkout (645d4fe14a20) lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/MustNotBooleanQueryBenchmark.java:82, :116, :175-178a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:1668-1676https://github.com/apache/lucene/pull/13542#discussion_r1686523892 (javanna, 2024-07-22)https://github.com/apache/lucene/pull/15795#discussion_r3274125232 (javanna, 2026-05-20)
- TestTotalHitCountCollectorManagerReuse.java (one test, public API):
repro/LFV-053/src/org/apache/lucene/search/TestTotalHitCountCollectorManagerReuse.java(66 lines)
Made from the code at 645d4fe14a20, the minimal test's runs and a small diagram probe that prints every value shown (see, "What each frame shows").
assets/LFV-053/bug.png(593 KB)
