All findings · Lucene finding · LFV-053 · count-manager-reuse-after-failure

Reusing a TotalHitCountCollectorManager after a failed search silently undercounts (0 in the repro) or hangs

With intra-segment partitions, a TotalHitCountCollectorManager reused after a failed search returns 0 hits for every segment the failed search had already counted, with no error: 0 instead of 2 in the repro. Every release from 10.0.0 to 10.6.0 and main is affected.

Summary

Priority
Low, rank 729Moved 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
silent wrong results
Since
10.0.0 (#13542, 2024)introduced 2024-09-10 · about 2 years
Affected
every release from 10.0.0 to 10.6.0 (all 13 run) and main; 9.x not affected
Status
documented
Found by
TLA+ model checking of the per-segment count coordination, then a JUnit

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

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.

Background: counting a segment that is split into partitions

  1. Intra-segment concurrency (opt-in) splits one segment into partitions; different collectors, possibly on different threads, search them.
  2. A total-hit count can often be read for a whole segment from index statistics (Weight#count), for example for MatchAllDocsQuery.
  3. 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.
Diagram of the bug: one segment of 2 documents split into 2 partitions, and one TotalHitCountCollectorManager. A successful search first shows how the count works: the first partition counts the whole segment from index statistics (2) and stores true in the manager's map, the other partition sees true and adds 0, and reduce() returns 2 and clears the map. Search 1 (the count plus a collector that throws): the first partition again stores true, then the application's collector throws on the first document, the second partition is cancelled, the search fails and reduce() never runs, so true stays in the map. Search 2 with the same manager: both partitions find the stale true and stop, and the count is 0 instead of 2, with no exception; a fresh manager or searcher.count() gives 2. If search 1 instead fails inside weight.count(), the stored future is never completed and search 2 waits forever. The summary card adds the reuse comment (TotalHitCountCollectorManager.java:87-89), the CollectorManager reduce javadoc (CollectorManager.java:30-32), the counter-argument and, as a side note, the study's fix idea.
Two searches with one manager: the failed search leaves the note "segment counted from statistics" in the manager's map, and the next search skips both partitions and returns 0. A last panel shows the hang when the failure is inside weight.count(). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 2 documents in one segment, open an IndexSearcher that splits the segment into 2 partitions (slices(leaves, 1, 1, true)), and create one TotalHitCountCollectorManager that you keep.
  2. Search MatchAllDocsQuery with MultiCollectorManager(countManager, yours), where your collector throws in collect(). 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".
  3. Search MatchAllDocsQuery again with the same countManager. You expect 2.
  4. 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 own reduce() clears the note, so a third search returns 2 again.
  5. At larger scale: 1,000 documents in 4 partitions and a stock ExitableDirectoryReader timeout; the retried request reports count 0 next to TopFieldDocs.totalHits = 1000. If the failure was inside Weight#count, the next search hangs instead.

Who is affected

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 mechanism (main 645d4fe14a20)

  1. 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 one Map<Object, Future<Boolean>> earlyTerminatedMap (a ConcurrentHashMap) per manager (:75), handed to every partition-aware collector (:77-83).
  2. The key never changes. getLeafCollector looks up earlyTerminatedMap.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.
  3. The first partition decides. TotalHitCountCollectorManager.java:111-125: the first partition of the segment to reach getLeafCollector (the code's "first thread for a given leaf", not necessarily the one with the lowest doc ids) stores a CompletableFuture and calls TotalHitCountCollector#getLeafCollector, which adds the whole segment's count and throws CollectionTerminatedException when weight.count(context) != -1 (TotalHitCountCollector.java:46-54); the manager completes the future with true (TotalHitCountCollectorManager.java:122-124), else with false (TotalHitCountCollectorManager.java:120).
  4. The decision is stored before the search fails. MultiCollector#getLeafCollector asks every sub-collector before any collect() (MultiCollector.java:123-143), so the count collector has stored true before the application's collector throws.
  5. 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"). IndexSearcher calls reduce after taskExecutor.invokeAll(...) (IndexSearcher.java:788-789), and when a task throws, invokeAll rethrows (TaskExecutor.java:118-135), so a search that fails during collection never gets there.
  6. The next search reads the stale note. A stored true makes every partition of that segment throw CollectionTerminatedException (TotalHitCountCollectorManager.java:130-134), the first partition of the new search included. IndexSearcher#searchLeaf treats that as "there is no doc of interest in this reader context" (IndexSearcher.java:835-841). The segment adds 0.
  7. Until a search completes. That search reaches reduce(), which clears the map, so the following search is right. A search that also fails before reduce() leaves stale entries for the next one.
  8. 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 in earlyTerminated.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 overriding slices and calling slices(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, including IndexSearcher#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, a TermQuery on a segment without deletions, ...): another collector in the MultiCollectorManager (the use the class javadoc names), an ExitableDirectoryReader timeout, an IOException. The IndexSearcher#setTimeout timeout does not count: it is caught and reduce() 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.totalHits 1000. With a real 4-thread pool, 200 of 200 rounds were wrong. If the failure was inside Weight#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 ExitableDirectoryReader per 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 TotalHitCountCollectorManager per 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.

Minimal repro

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.

TestTotalHitCountCollectorManagerReuse.java (one test, public API)66 lines
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));
      }
    }
  }
}
Command
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>.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Also fails on every release from 10.0.0 to 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

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)

Why it looks unintended

  • 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.
  • CollectorManager calls reduce only after full collection (CollectorManager.java:30-32), so a reset in reduce() 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#testBasics runs two searches with one manager on a newSearcher with a random concurrency, which may be intra-segment (TestTotalHitCountCollector.java:44-57). MustNotBooleanQueryBenchmark keeps 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 before reduce(). In #15795 (2026) they wrote "Collectors managers may be reused across multiple executions".
  • Where reuse is not allowed, Lucene says so. TopScoreDocCollectorManager.java:26 and TopFieldCollectorManager.java:27: "Note that a new collectorManager should be created for each search due to its internal states." Nothing in CollectorManager, 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.

Suggested fix

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.

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's Weight (and what it references, such as the query) reachable. Repeated failures with no successful search in between add one each.
  • The key is the Weight object. Two searches that share one Weight and one manager would share decisions (IndexSearcher#search(Query, CollectorManager) creates a new Weight per call). The map compares keys with equals/hashCode, which Weight does not override, so Lucene's own weights are told apart by identity; a custom Weight that 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's reduce() clears the map while another runs); not tested.
  • Needs setWeight. A getLeafCollector call without a prior setWeight works today (TotalHitCountCollector treats a null weight as "no count shortcut", TotalHitCountCollector.java:48) but would hit a null map with this fix; IndexSearcher always 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 use newSearcher, which picks intra-segment partitions at random; no coverage was measured.
  • checkGoogleJavaFormat, ecjLint and forbiddenApis (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 the ExitableDirectoryReader timeout 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.

LFV-045's minimal fix alone (not enough)

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.

Document "create a new manager per search" (not recommended)

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.

Downstream note

Search servers that keep one count manager across requests on a partitioned searcher can avoid this by creating a new TotalHitCountCollectorManager per search.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-31109 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 search2026-10-3115 GraphQL queries and 3 code searchesno report; no copy of the per-manager map outside Lucene and verbatim ports
Mailing lists, JIRA archive, web2026-10-3160 list queries (dev@, java-user@, issues@, commits@), 10 JIRA and 6 web queries; the dev@ thread on intra-segment concurrency read in fullno report
Open apache/lucene PRs2026-10-31changed files of all 409 open PRs; diffs of those touching IndexSearcher's invokeAll/reduce path readonly #16847 edits the class, without changing the clean-up

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and standalone, assertions on and off; 20 of 20 derived seeds)
10.0.0 to 10.6.0 (13 releases) affectedthe 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 APIthe test does not compile; the same steps with the 9.x manager count 2
main 645d4fe14a20 with the fix not affectedthe minimal test and the closest core suites pass, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier (independent test) new bugOwn 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 realMinimal 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 realMinimal 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.0Minimal 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 realGradle 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) holdsRe-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.

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

Finding record

LFV-053, snapshot of 2026-11-06

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 main 2f424fd428da (2026-10-30) has the same TotalHitCountCollectorManager.java blob [read].

Lucene and upstream sources

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-144
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TotalHitCountCollector.java:46-54
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:525, :788-789, :808-815, :835-841, :856-857
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TaskExecutor.java:70-81, :118-135
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MultiCollector.java:123-143
  • a 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-857
  • a 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-89
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/CollectorManager.java:23-55
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:26; TopFieldCollectorManager.java:27
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestTotalHitCountCollector.java:44-57
  • a Lucene main checkout (645d4fe14a20) lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/MustNotBooleanQueryBenchmark.java:82, :116, :175-178
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:1668-1676
  • https://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)

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

Repro files

About the diagrams

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

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.