All findings · Lucene finding · LFV-465 · count-two-term-disjunction-timeout-overcount

IndexSearcher#count on a two-term OR can return more than maxDoc when the searcher timeout fires

With a searcher timeout set, IndexSearcher#count(a OR b) can return more matches than the index has documents: 23 on a 22-document index where 21 match. Since 9.10.0 it computes count(a) + count(b) - count(a AND b), and the timeout cuts only the last count short.

Summary

Priority
Low, rank 591Under the opt-in timeout, a timed-out IndexSearcher#count on a skewed two-term disjunction over-counts by less than 10% of the true count, sometimes above maxDoc, but timedOut() is true.
Impact
silent wrong results
Since
9.10.0 (GITHUB#13036, merged 2024-02-01); 10.0.0 on the 10.x lineintroduced 2024-02-01 · about 2 years
Affected
9.10.0 to 10.6.0 and main, with the opt-in IndexSearcher#setTimeout
Status
documented
Found by
AI tools placing a timeout at every point of 1.2M searches, then a Z3 model

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

The finding in 30 seconds

IndexSearcher#setTimeout stops a search that runs out of time, keeps what it found so far and sets timedOut(). count() has a shortcut for two-term ORs: add the two term counts, subtract the overlap. It is exact only when the whole overlap gets counted.

Summary of the bug: 22 documents, term a in 21 of them, term b in 2, one with neither, so a OR b matches 21. The searcher has a timeout that has already expired. IndexSearcher#count(a OR b) takes the two-term shortcut count(a) + count(b) - count(a AND b): count(a) = 21 and count(b) = 2 come from index statistics and are exact, but count(a AND b) is collected and the timeout stops it at 0 instead of 2. Without a timeout 21 + 2 - 2 = 21; with the expired timeout 21 + 2 - 0 = 23, more than the 22 documents in the index. The fix sketch adds queryTimeout == null to the shortcut's guard.
The repro: count(a) = 21 and count(b) = 2 come exact from index statistics; the timeout stops count(a AND b) at 0 instead of 2, so docs 0 and 1 are counted twice: 21 + 2 - 0 = 23 > maxDoc 22. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 22 documents, no deletions: term a in docs 0-20 (21 documents), term b in docs 0-1 (2 documents, which also have a), doc 21 with neither. a OR b matches 21 documents.
  2. Give the searcher a timeout that has already expired, setTimeout(() -> true), and call count(a OR b). You expect a partial count, at most 21: collecting the same query under the same timeout gives 0, and count() on 9.9.2 returns 0.
  3. You get 23, more than the 22 documents in the index, with timedOut() true. Because one term count is under a tenth of the other, count() takes its shortcut count(a) + count(b) - count(a AND b).
  4. count(a) = 21 and count(b) = 2 come exact from index statistics, which never check the timeout. count(a AND b) has to be collected, and the timeout stops it at 0 instead of 2: 21 + 2 - 0 = 23, so docs 0 and 1 are counted twice.

Who is affected

Applications that opt into IndexSearcher#setTimeout and use the number a timed-out count() returns, for an OR of exactly two terms whose counts differ more than tenfold, on a reader without deletions. The number is too high by under 10% of the true count and can exceed maxDoc. Callers that discard timed-out results are unaffected.

Mechanism, terms and numbers752 words

Since 9.10.0 (GITHUB#13036), IndexSearcher#count counts a pure OR of two TermQuery clauses as count(a) + count(b) - count(a AND b) when the reader has no deletions, both term counts are non-zero and one is under a tenth of the other. The two term counts come from index statistics and are always exact; the intersection has to be collected, and a searcher timeout (IndexSearcher#setTimeout) can cut that collection short.

Subtracting a cut-short intersection over-counts. With an already-expired timeout the 22-document repro returns 23 for 21 matching documents, above maxDoc 22, with timedOut() true; collecting the same query under the same timeout gives 0, and 9.9.2 returns 0 too. The result is never below the true count and too high by at most the intersection, which the shortcut's 0.1 ratio keeps under 10% of the true count. Callers that discard timed-out results are unaffected; a cached intersection hides the bug on later calls.

Terms

  • Searcher timeout. IndexSearcher#setTimeout(QueryTimeout) applies to all searches of that searcher (no timeout by default). When it fires, collection stops early, the search keeps what it found so far and timedOut() becomes true; the flag is the field partialResult (IndexSearcher.java:89, :94, :613, :647-650, :845-858).
  • count(Query). "Count how many documents match the given query. May be faster than counting number of hits by collecting all matches, as the number of hits is retrieved from the index statistics when possible." (IndexSearcher.java:490-494). Without deletions, the count of a TermQuery is the term's docFreq (TermQuery.java:255-260).
  • The two-term shortcut (GITHUB#13036, 9.10.0). For a OR b made of exactly two TermQuery SHOULD clauses (minimumNumberShouldMatch <= 1), on a reader without deletions, when both term counts are non-zero and the smaller is under a tenth of the larger, count() returns count(a) + count(b) - count(a AND b) (inclusion-exclusion, IndexSearcher.java:506-518).

The bug, step by step

  1. The shortcut's guard checks the query shape and reader.hasDeletions() == false (IndexSearcher.java:507-509), never the timeout.
  2. count(a) and count(b) are docFreq values. TotalHitCountCollector#getLeafCollector reads them before any scorer exists (TotalHitCountCollector.java:47-52), so they are exact under any timeout.
  3. count(a AND b) cannot be read from statistics: BooleanWeight's reqCount returns -1 for two clauses whose counts are in [1, numDocs) (BooleanWeight.java:225-227). So it is collected with a TotalHitCountCollectorManager (IndexSearcher.java:525-528) through searchLeaf, which wraps the scorer in a TimeLimitingBulkScorer (:846-847). That scorer checks shouldExit() before each window and throws (TimeLimitingBulkScorer.java:80-81); searchLeaf catches it, sets partialResult and keeps the count so far (IndexSearcher.java:856-857).
  4. :518 subtracts that cut-short intersection, so the result is the true count plus the part of the intersection that was not counted. It is never below the true count. It equals the true count exactly when the whole intersection was counted before the timeout fired (always, if the two terms share no document: executed, 23 for 23 matching documents with timedOut() true). Otherwise it is too high by at most the intersection, which the 0.1 ratio keeps under 10% of the true count.

The numbers

  • Repro: 22 documents, a in 21, b in 2 (both also have a), already-expired timeout: count() = 21 + 2 - 0 = 23 > maxDoc 22 (true count 21), timedOut() true. Collecting the same query under the same timeout gives 0; 9.9.2 gives 0.
  • Timeout firing later (single thread): on 2,000 documents (a in 1,960, b in 100, 80 in both, true count 1,980), a timeout that lets its first n checks pass, n = 0..5: count() = 2060, 2056, 2050, 2040, 2027, 2006; collecting gives 0, 99, 248, 471, 805, 1305; the intersection was cut to 0, 4, 10, 20, 33, 54 of 80. At n = 6 the search completes and both give 1980. With the fix, count() equals the collected value at every n.
  • Same 23 for the OR under BoostQuery or ConstantScoreQuery, for the classic QueryParser's parse of a b, and with the stock QueryTimeoutImpl and a passed deadline.
  • Bound (executed): 2,247 over-counts among 7,398 timed count() calls (4 runs of 80 random indexes each; 3 seeds, one of them run with assertions on and off, so 1,690 over-counts in 5,492 distinct calls), every one with timedOut() true and an excess under 10% (largest 7.9%; a constructed ratio of 0.099 gives 1099 for 1000), none on an index with deletions. From code reading only: the sum is int arithmetic, so near the 2^31-document limit it could wrap negative instead.
Exposure and likelihood417 words
  • Who is hit. Applications that set IndexSearcher#setTimeout (no timeout by default, IndexSearcher.java:89) and use the value of a timed-out count(), for example as "at least N results", for a query of exactly two TermQuery SHOULD clauses (plain, or under BoostQuery or ConstantScoreQuery) whose document counts are both non-zero and differ more than tenfold, where the larger term is not in every document, on a reader without deletions. A two-word user query parsed by the classic QueryParser with its default OR operator (QueryParserBase.java:78-81) has that shape when each word is one term (executed: 23).
  • When. The timeout fires while the intersection is collected, or has already expired when count() starts, for example when one searcher and one deadline serve several calls and earlier calls used the time.
  • Who is not. Callers that discard or retry a result once timedOut() is true (it is true in every over-count; the flag is sticky and searcher-wide, so a true flag says that some search on this searcher timed out, not that this count() was cut short, IndexSearcher.java:647-650); readers with any deletion (the shortcut is off); queries with a third clause, a FILTER, MUST or MUST_NOT clause, minimumNumberShouldMatch > 1 or a non-term clause; term counts within 10x of each other.
  • A cached intersection hides it, on later calls. A cached intersection is counted exactly. On main the default query cache is off (GITHUB#14187, 11.0.0). In 9.x and 10.x releases the default LRUQueryCache caches only on segments with at least 10,000 documents that also hold more than half of the index's average documents per segment, skips clauses that cost much more than the top-level query, and caches a BooleanQuery only after a few uses: with that cache installed on a 20,000-document segment, the first 4 timed-out counts gave 19200 (true 18200), later ones 18200. The call that fills the cache is not hidden: the cache is filled from the unwrapped scorer while the timeout still applies. Once the intersection is cached, by any earlier call, timed or not, later timed calls are exact (executed: after an untimed count() on a shared cache, the first timed call returned the exact 21 with timedOut() false).
  • Effect. One number, too high by under 10% of the true count, possibly above maxDoc, on a result flagged as timed out. No documents are returned, nothing extra matches, nothing is cached wrong or written.
  • How likely. Rare (registry): each condition is ordinary, together they are narrow, and the flag is set.

Minimal repro

One JUnit test on the public API: 22 documents, a two-term OR, a timeout that has already expired and one count() call. Deterministic and single-threaded; it fails with assertions on and off and is also the suggested regression test.

TestTimedOutCountTwoTermDisjunction.java (JUnit for Lucene's build)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import 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.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
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 TestTimedOutCountTwoTermDisjunction extends LuceneTestCase {

  public void testTimedOutCountExceedsMaxDoc() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 22; i++) {
          Document doc = new Document();
          if (i < 21) doc.add(new StringField("f", "a", Field.Store.NO));
          if (i < 2) doc.add(new StringField("f", "b", Field.Store.NO));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        IndexSearcher s = new IndexSearcher(r);
        s.setQueryCache(null);
        s.setTimeout(() -> true);
        Query aOrB =
            new BooleanQuery.Builder()
                .add(new TermQuery(new Term("f", "a")), BooleanClause.Occur.SHOULD)
                .add(new TermQuery(new Term("f", "b")), BooleanClause.Occur.SHOULD)
                .build();
        // a in docs 0-20, b in docs 0-1: expected a timed-out count <= maxDoc 22, actual 23
        int count = s.count(aOrB);
        assertTrue(
            "expected a timed-out count() <= maxDoc 22 (21 docs match), got " + count,
            count <= r.maxDoc());
      }
    }
  }
}
Command
cp $REPRO/TestTimedOutCountTwoTermDisjunction.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestTimedOutCountTwoTermDisjunction -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
A timed-out count() of at most 21, the matching documents, and never above maxDoc 22.
Observed
count() returns 23 (= 21 + 2 - 0), with assertions on and off.

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

More files, environment, full output334 words

The test indexes 22 documents into a ByteBuffersDirectory with the default IndexWriterConfig: term a in docs 0-20, term b in docs 0-1, doc 21 with neither. So the ratio 2/21 is under 0.1, a is not in every document (otherwise the intersection would be counted exactly from statistics) and maxDoc is 22, the smallest index that can exceed maxDoc this way. It asserts only that the timed-out count() is at most maxDoc, the bound no reading of the contract can break; the message also gives the true count.

setQueryCache(null) mirrors main's default (no query cache) and keeps the test independent of caching. It is not required: with LuceneTestCase's randomly caching default cache left on, the test still fails (40 of 40 iterations with one seed, 20 of 20 with each of two more, and 30 of 30 with a fourth), because a query cache is filled from the unwrapped scorer and can hide the bug only on later calls. Dropping the line is fine.

Nothing in the test uses random(); it also failed 10 of 10 times with seed 5EED1234. It compiles unchanged against 9.3.0 and later; 9.2.0 has no setTimeout.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on every release from 9.10.0 to 10.6.0; 9.9.2 is the last release that passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, stock main 645d4fe14a20, seed DEADBEEF (the same with -Ptests.asserts=false)
TestTimedOutCountTwoTermDisjunction > testTimedOutCountExceedsMaxDoc FAILED
    java.lang.AssertionError: expected a timed-out count() <= maxDoc 22 (21 docs match), got 23
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E7A55C79C5E0BE33]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.search.TestTimedOutCountTwoTermDisjunction.testTimedOutCountExceedsMaxDoc(TestTimedOutCountTwoTermDisjunction.java:53)

1 test completed, 1 failed

# value probe on the same index and query (not the repro), main 645d4fe14a20
stock:  PROBE A EXPIRED TRACE depth=1 count(f:a)=21 timedOut()=false
stock:  PROBE A EXPIRED TRACE depth=1 count(f:b)=2 timedOut()=false
stock:  PROBE A EXPIRED TRACE depth=1 count(+f:a +f:b)=0 timedOut()=true
stock:  PROBE A EXPIRED RESULT count(f:a f:b)=23 timedOut()=true shouldExitCalls=1 maxDoc=22
stock:  PROBE A EXPIRED COLLECT TotalHitCountCollectorManager(f:a f:b)=0 timedOut()=true
fix A:  PROBE A EXPIRED RESULT count(f:a f:b)=0 timedOut()=true shouldExitCalls=1 maxDoc=22

Why it looks unintended

  • count() is documented to "Count how many documents match the given query"; 23 in a 22-document index is no such count, partial or not.
  • Under the same timeout, collecting the same query gives 0, and count() on 9.9.2 returns 0.
  • Timeouts are designed to return partial results; the shortcut's tests never set one.
The full argument273 words
  • count() counts matching documents (IndexSearcher.java:490-494). A value above maxDoc cannot be such a count under any reading.
  • Timed-out results are partial results. The flag behind timedOut() ("Returns true if any search hit the timeout", :647-650) is the field partialResult (:94). The timeout design issue #11188 (LUCENE-10151) says: 'If the search "times out", partial results would be available.' In a review comment on #927 (2022-06-01), jpountz proposed the design that shipped: the timeout "applies to all search/count methods", the methods "never propagate the exception, otherwise we're preventing users to retrieve partial results", and the flag lets users know "that some matches were skipped".
  • Lucene's own tests treat timed-out values as partial. For counts, BaseExplicitVectorSimilarityQueryTestCase.java:518 expects count() == 0 under setTimeout(() -> true) ("Expect no results with the timeout") and :524 expects 0 < count < numDocs ("Expect partial results"); BaseVectorSimilarityQueryTestCase.java:637, :643 do the same. For hits, TestTimeLimitingBulkScorer.java:62: "Partial result and is aborted is true". These test particular queries, not a general javadoc contract, but they show what a timeout is expected to produce.
  • The other paths we ran, and older releases, stay at or below the true count. Under the same expired timeout, TotalHitCountCollectorManager and a three-clause OR give 0; 9.3.0 to 9.9.2 pass the repro (value 0 probed on 9.3.0 and 9.9.2).
  • Never tested with a firing timeout. #13036's test uses a searcher without a timeout, an IndexSearcher subclass that counts its count() calls (TestBooleanQuery.java:961-1097, the subclass at :991-998); the test framework only installs a never-firing timeout, setTimeout(() -> false) (LuceneTestCaseParent.java:2690); #13036's body, comments and reviews and #12644 never mention timeouts.
The devil's-advocate check165 words

The strongest case for "works as designed" (from the cross-model reviews, the intended-behaviour checker and the adjudication): no javadoc defines the value of a timed-out count(); timedOut() is sticky and searcher-wide, so a careful caller discards every number once it is true; and timed-out counts follow no single rule anyway, because without deletions count(TermQuery) returns the exact docFreq without consulting the timeout.

Why it fails: an exact count is a valid partial count, a count above maxDoc is not; the design record, the field name and Lucene's own tests treat timed-out values as partial results that callers may use; count() presents itself as a faster way to get what collecting gives, and collecting the same query stays at or below the true count; and 9.9.2 returned 0 here. These points lower the impact (flag set, excess under 10%); none makes the value intended. Declaring timed-out counts unspecified would be a contract change that nothing in the code or tests states today. Verdict: a defect.

Suggested fix

Recommended A: skip the two-term shortcut when a timeout is set (validated)

Skip the two-term shortcut when the searcher has a timeout: add && queryTimeout == null to its guard in IndexSearcher#count. Timed searchers then take the ordinary count path; others keep the speed-up. Validated: the repro passes, and so do TestBooleanQuery, the timeout tests and TestIndexSearcher (80 tests), with assertions on and off.

      // Check if two clause disjunction optimization applies
+      // Inclusion-exclusion needs an exact intersection count: with a timeout, a partial
+      // intersection count would make the result exceed the true count.
       if (innerQuery instanceof BooleanQuery booleanQuery
           && this.reader.hasDeletions() == false
+          && queryTimeout == null
           && booleanQuery.isTwoClausePureDisjunctionWithTerms()) {
All options and trade-offs2 options · 491 words

One fix was built and validated with gradle on 645d4fe14a20; the alternative comes from the study's notes and code reading. The defect is the value above the true count; which fallback a timed searcher gets is the maintainers' choice.

A: skip the two-term shortcut when a timeout is set (validated) (recommended)

Add queryTimeout == null to the shortcut's guard in IndexSearcher#count (IndexSearcher.java:507-509): one condition plus a 2-line comment. A searcher with a timeout then takes the ordinary count path (BooleanWeight#count where statistics suffice, else collecting the OR), which under a timeout gives a partial count (0 in the repro) like the other paths; searchers without a timeout keep the GITHUB#13036 speed-up unchanged.

      // Check if two clause disjunction optimization applies
+      // Inclusion-exclusion needs an exact intersection count: with a timeout, a partial
+      // intersection count would make the result exceed the true count.
       if (innerQuery instanceof BooleanQuery booleanQuery
           && this.reader.hasDeletions() == false
+          && queryTimeout == null
           && booleanQuery.isTwoClausePureDisjunctionWithTerms()) {

Trade-offs

A searcher with any timeout, even one that never fires, loses the shortcut and collects the union instead of only the small intersection. It is a containment fix; whether it is the preferred design is the maintainers' call.

What the checks validated

  • Built in a fresh scratch worktree of 645d4fe14a20 with gradle (removed after the runs). Stock, the repro fails with assertions on and off (23 > maxDoc 22).
  • With A, the repro plus TestBooleanQuery (30 tests, including the shortcut's own testTwoClauseTermDisjunctionCountOptimization), TestTimeLimitingBulkScorer (2), TestIndexSearcher (11) and TestFloatVectorSimilarityQuery / TestByteVectorSimilarityQuery (18 and 18, whose testTimeout checks timed-out count() values): 6 classes, 80 tests, 0 failures, 3 skipped, with assertions on and with -Ptests.asserts=false; :lucene:core:checkGoogleJavaFormat passes with the fix and the repro file in place.
  • A value probe on the same index shows the fixed timed-out count: 0 with an expired timeout, and on 2,000 documents exactly the collected partial count at every timeout point (0, 99, 248, 471, 805, 1305).
  • The same validation ran earlier the same day in another scratch worktree and in the independent fact check, with the same counts; the executed cross-model review's own test also passes with the guard.

B: keep the shortcut, fall back when the intersection count was cut short (not built)

Count the intersection as today; if that count timed out, collect the OR instead, or return max(count(a), count(b)): both term counts are exact from index statistics, and their maximum is a lower bound of the union, so it never exceeds the true count (code reading; the shortcut already returns this maximum when one term count is 0, IndexSearcher.java:511-514).

Trade-offs

Keeps the speed-up for timed searchers whose count completes. It needs a per-call "this count timed out" signal: partialResult is one sticky flag for the whole searcher ("Returns true if any search hit the timeout", :647-650) and may already be true from an earlier search, so it is more code than A.

Downstream note

In a service that counts end-user queries under a per-request timeout, a two-word query parsed with the default OR operator has the triggering shape; the too-high number reaches only that request, which is already flagged as timed out.

How it was found

AI tools placed a timeout at every possible point of 1,228,664 searches on the real classes and compared each result with a reference written from the javadoc. Only count() on skewed two-term ORs without deletions exceeded the true count. A Z3 model confirmed the arithmetic, and the case was reduced to a 22-document test.

The method in full149 words

Method: semi-formal (bounded-exhaustive translation validation on the real classes, then an SMT model).

  • A finder ran every search of a grid (69 query shapes x 9 collectors x 4 slicing modes x 3 reader modes on 4 index shapes) with a QueryTimeout that fires from its n-th shouldExit() call on, for every n below the call count of the full search: 1,228,664 searches, each compared with a reference written from the javadoc.
  • Its count upper-bound and count-monotonicity checks fired only for IndexSearcher#count on the skewed two-term SHOULD disjunctions (also under BoostQuery) on indexes without deletions, in every slicing mode.
  • A Z3 bit-vector model of the count path showed the untimed shortcut exact (UNSAT) and an over-count with a truncated intersection reachable (SAT); with the guard, the lower bound holds (UNSAT).
  • The case was minimised to a deterministic JUnit on the stock jars, later reduced to the 22-document test.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26 and twice on 2026-10-30): GitHub issues, PRs and commits, the Elasticsearch, OpenSearch and Solr trackers, the Lucene mailing lists, the web, and the diffs of all open PRs that touch these files. Not fixed upstream.

Searches6 searches

No issue, PR, review comment, commit, CHANGES entry, list thread or downstream issue found reports a timed-out count() above the true count; keyword search cannot prove absence. The closest items explain the code, not the bug: #13036 and #12644 introduced the shortcut and never mention timeouts; #16001 kept it; open #16847 only widens the counts to long; open #16952 limits the bulk-scoring bound in searchLeaf; open #16112 is about slices that keep working after a timeout; #11188 and #927 are the timeout design (partial results); #11188 item 2 (a timed-out count labelled EQUAL_TO whose value is still a valid lower bound) and #14639 (a result derived from a timed-out kNN sub-search) are different defects.

What was searched, when, and what turned up
WhereWhenHowFound
Prior-art tool: GitHub issues, PRs and commits (apache/lucene), downstream trackers, mailing lists, JIRA2026-10-30fresh run with new phrasings: 45 issue/PR, 6 commit, 5 downstream (Elasticsearch, OpenSearch, Solr), 28 mailing-list (dev@, java-user@, issues@, commits@) and 6 JIRA queries; 3,904 distinct hitsno report; top hits are known context (#13202, #927, #12358, #13036, #12275, #16001)
GitHub GraphQL, apache/lucene and all of GitHub2026-10-3039 searches: identifiers (isTwoClausePureDisjunctionWithTerms, rewriteTwoClauseDisjunctionWithTermsForCount, countTerm1, partialResult, TimeLimitingBulkScorer, TimeExceededException, QueryTimeout), 13036, inclusion-exclusion and symptom phrasings; every apache/lucene item created or updated since 2026-10-29only the items that introduced the shortcut, and unrelated hits; one new related hit, #16112
Downstream GitHub trackers2026-10-3030 searches on Elasticsearch, OpenSearch and Solr; 5 items fetched and greppednothing about Lucene's count shortcut under a timeout
Open PRs2026-10-30changed files of all 408 open PRs; diffs of the 19 that touch IndexSearcher, BooleanQuery, BooleanWeight, TermQuery or ConstantScoreQuery grepped for the guard identifiersnone changes the guard; #16847 only widens the counts to long
Mailing lists by hand, web2026-10-3027 list queries (dev@, java-user@, issues@) and the 10 mails of the dev@ thread; 8 web queriesno report; no maintainer statement of prior knowledge
Earlier searches2026-10-26 and 2026-10-30 (morning)the study follow-up (two tool runs, 44 GitHub searches, 18 list, 11 web queries, all 408 open PRs) and an earlier run of this step (79 tool queries, 28 GraphQL queries, 410 open PRs)none the same bug

History

Introduced by GITHUB#13036 (merged 2024-02-01), first released in 9.10.0 and, on the 10.x line, 10.0.0. Executed: every release from 9.10.0 to 10.6.0 and main returns 23; 9.3.0 to 9.9.2 pass. Applying or reverting only that change on the release jars flips the result.

Versions tested9 rows

Introduced by GITHUB#13036 "Optimize counts on two clause term disjunctions" (author jfreden, merged by jpountz on 2024-02-01; origin issue #12644): c845bf02fefc (branch_9x backport, first released in 9.10.0) and b537e1da2760 (main, first released in 10.0.0). It added the shortcut with a guard that checks deletions and query shape but not the timeout. The timeout itself (LUCENE-10151 / #927) is older, first released in 9.3.0. #16001 (10.5.0) restructured count() and kept the guard and the arithmetic.

Not fixed: upstream main 2f424fd428da (2026-10-30, the newest head read; the prior-report search ran against 208608f75307), branch_10_6 and branch_11x keep the guard without a timeout check (read). Not run: x86; all runs OpenJDK 25.0.3 on Linux aarch64.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 (unreleased 11.0) affected23 > maxDoc 22, -ea and -da, gradle and standalone
10.0.0 to 10.6.0 (13 releases) affected23 (10.0.0, 10.5.2, 10.6.0 also -da)
9.10.0 to 9.12.3 (7 releases) affected23 (9.10.0 also -da)
9.3.0 to 9.9.2 (11 releases) not affectedpass (9.3.0 and 9.9.2 also -da); value 0 probed on 9.3.0 and 9.9.2
9.2.0 not applicableno IndexSearcher#setTimeout; the repro does not compile
9.9.2 + only the c845bf02fefc diff attribution23 (-ea and -da)
9.10.0 with only the c845bf02fefc diff reversed attribution0 (-ea and -da)
10.0.0 minus only b537e1da2760's 21 count() lines attribution0
main + fix A not affectedthe repro passes (timed-out count 0)

Independent checks

Every check that ran code reproduced it: the finder, independent reproducers, the adjudication, an executed cross-model review (OpenAI GPT-5.6) with its own test, independent fact checks and this page's gradle runs. The only dissent was an earlier review that could not run code; it agreed on the root cause.

Checks and verdicts652 words

Every check that executed code voted real. The only dissent is procedural: the first cross-model refute review could not run Java and voted not real on procedure only at low confidence while agreeing on the root cause; the 240-document scenario it proposed then failed as it predicted (250 > maxDoc 240). The second round of that review ran its own 31-document test (32 > maxDoc 31). The fact check of the first version of this page found one error, the reason given for setQueryCache(null) in the repro. The fact checks of the second version found three wording and count errors (when a timed-out count is exact, what a cache can hide, a run count) and, in a read-only cross-model check, two more plus overclaims and missing caveats. All are corrected here; none changes the bug, the repro or the fix.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateBounded-exhaustive timeout placement (1,228,664 searches) and a Z3 model; its JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and 9.10.0 and passes on 9.9.2.
Cross-model review, refute lens, round 1 (OpenAI GPT-5.6) not real, low (procedural)Could not run code; root cause agreed. Its proposed scenario was run and failed as it predicted.
Cross-model review, intended lens (OpenAI GPT-5.6) real, mediumCould not run code; intended-behaviour argument moderate and rejected. A separate run re-ran the repro: fails.
Independent reproducer realOwn JUnit (2,000 documents): 2060 > maxDoc 2000 and the n = 0..5 sweep; fails on main, 10.5.2, 9.12.3 and 9.10.0, passes on 9.9.2 and with the guard.
Intended-behaviour devil's advocate realOwn test outside org.apache.lucene with the stock QueryTimeoutImpl: 1041 > maxDoc 1000 on main (-ea, -da) and 10.5.2; the collector and a three-clause OR give 0.
Adjudication confirmed, highRe-ran the independent reproduction's test (3 of 4 fail, -ea and -da) and the intended-behaviour check's (fails); framing advice adopted here.
Minimal repro verifiedFails on main 645d4fe14a20 (-ea, -da, 10 of 10 with a second seed); passes with the guard.
Cross-model review, refute lens, round 2 (OpenAI GPT-5.6) real, highExecuted: its own 31-document test with FSDirectory and no cache setting gives 32 > maxDoc 31 for three seeds, -ea and -da; with the guard its test, TestBooleanQuery and TestTimeLimitingBulkScorer pass; re-run twice on 645d4fe14a20 with the same outcome.
Re-verification realThe repro fails on main, 10.5.2 and 10.6.0 (-ea, -da) and passes with the guard; without setQueryCache(null) it still fails 20 of 20 for two seeds.
Independent fact check of this page's first version holds; one error, fixedRe-ran the gradle commands, fix A, 7 releases and main, and a probe of every stated shape; its one error (the stated reason for setQueryCache(null)) and the wording findings of a read-only cross-model check are corrected here.
Independent fact check of this page's second version holds; three errors, fixedRe-ran the gradle commands and fix A in its own scratch worktree (-ea, -da), the repro on main and 6 releases, the repro without setQueryCache(null) (30 of 30 fail) and two value probes. Its errors: a timed-out count is also exact when the terms share no document, a cache filled by an earlier call does hide later timed calls, and a run count; all fixed here.
Read-only cross-model fact check of the second version (OpenAI GPT-5.6) sound; wording fixedCould not run code; read the sources and the recorded runs. Bug, repro, mechanism and fix confirmed; 3 errors (the newest upstream head, the empty-intersection case, the max() fallback wording), 3 overclaims and 4 missing caveats, all addressed here.
This page's gradle runs reproduced; fix A validatedGradle on 645d4fe14a20: the repro fails stock with assertions on and off; with fix A it passes, and 6 classes / 80 tests (TestBooleanQuery, TestTimeLimitingBulkScorer, TestIndexSearcher, the two vector-similarity suites) pass with assertions on and off; the format check passes.

Diagram file

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

Sources and provenance

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

Finding record

LFV-465, snapshot of 2026-11-06

Registry title
IndexSearcher#count on a two-term disjunction over-counts, even above maxDoc, when the searcher timeout fires while the intersection is counted
Area
search / IndexSearcher#count (two-clause term-disjunction shortcut, GITHUB#13036) under IndexSearcher#setTimeout
Priority
Low, rank 591: Under the opt-in timeout, a timed-out IndexSearcher#count on a skewed two-term disjunction over-counts by less than 10% of the true count, sometimes above maxDoc, but timedOut() is true.
Impact class
silent wrong results
Impact note
Applications that opt into IndexSearcher#setTimeout and use the value of IndexSearcher#count for a pure two-term SHOULD disjunction (plain, or wrapped in BoostQuery or ConstantScoreQuery) on a reader without deletions, where one term matches fewer than a tenth as many documents as the other, get a count above the true number of matches (by less than 10% of it, and possibly above maxDoc) whenever the timeout fires while the uncached intersection is being counted; timedOut() is correctly true, nothing is persisted, ordinary scale, every release from 9.10.0 through 10.6.0 and main.
Summary
You set a timeout with IndexSearcher#setTimeout and call IndexSearcher#count on a query like a OR b, where term b matches far fewer documents than term a, on an index without deletions. When the timeout fires you expect a partial count, at most the number of matching documents, which is what every other count and collect path returns under the same timeout (and what this path returned up to 9.9.2). Instead count() returns count(a) + count(b) minus a cut-short count of a AND b, which is too high (1041 for a 1000-document index with 955 matches when the deadline has already passed) and gets smaller, not larger, when the timeout fires later; timedOut() is true.
Introduced
c845bf02fefc3fa5c70a811fa086fb890f7a7add (branch_9x backport, committed by jpountz, first released in 9.10.0) and b537e1da2760df5b52f4e5610df89e58c51c1a5f (main, first released in 10.0.0): GITHUB#13036 'Optimize counts on two clause term disjunctions' by jfreden, merged by jpountz (origin issue #12644). It added the count(a) + count(b) - count(a AND b) shortcut to IndexSearcher#count with a guard that checks deletions and query shape but not the timeout, on top of the IndexSearcher#setTimeout feature of LUCENE-10151 / #927 (af05550ebfe on main, aa082b46f66 on branch_9x, 2022-06-29, first released in 9.3.0) [exec: the 9.9.2 jars plus only the c845bf02fefc IndexSearcher/BooleanQuery hunks (shadow classes) fail 3 of 3 with the 9.10.0 values; the 9.10.0 jars minus only those hunks pass 3 of 3 with the 9.9.2 values; the commits themselves were not built from source] [read: git log -S isTwoClausePureDisjunctionWithTerms lists only these two commits; git tag --contains; CHANGES.txt 9.10.0 Optimizations entry for GITHUB#13036; git blame -w on main IndexSearcher.java:506-519 gives b537e1da2760 for every shortcut line except the instanceof line, which #16001 touched] (2024-02-01 (both commits; #13036 merged 2024-02-01))
First affected version
9.10.0 [exec] on the 9.x line (via c845bf02fefc); 10.0.0 on the 10.x line [exec]; 9.9.2 (released 2024-01-29) and every release back to 9.3.0 are not affected [exec, 11 releases]; before 9.3.0 IndexSearcher#setTimeout does not exist [read: 9.2.0 source]
Affected versions
9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, 10.6.0 (release jars [exec], below) and main ea57360912ed [exec: all 19 releases up to 10.5.2 and main fail the follow-up's public-API history probe (-ea; main also -da) with identical values (1041, 1032, 1018, 998, 969 against a true count of 955); the finder's JUnit fails on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.10.0; the independent reproduction's JUnit on main (-ea and -da), 10.5.2, 9.12.3 and 9.10.0; the intended-behaviour check's on main (-ea and -da) and 10.5.2]. Not affected: 9.3.0-9.9.2 [exec, 11 releases: a timed-out count is a lower bound, at most 764 or 776 for 955]; before 9.3.0 there is no IndexSearcher#setTimeout [read]. 10.6.0 (released 2026-10-29) fails the doc's repro on its release jars with -ea and -da [exec, 2026-10-30]. Not checked: unreleased 10.7.0, x86 (all runs aarch64, JDK 25). Not fixed upstream: upstream main 49483e82b35 (2026-10-26) keeps the guard without a timeout check, and no open PR changes it [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by Z3
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: two runs of the scripted prior-report search (58 queries in run 2 over apache/lucene issues and PRs, commits, downstream trackers, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 25 manual GitHub GraphQL searches over apache/lucene and 19 over all of GitHub and the Elasticsearch, OpenSearch and Solr trackers, 18 manual list searches, 11 web queries, git history of the shortcut, CHANGES.txt, upstream tests, and the changed files of all 408 open apache/lucene PRs (17 touch the files; their diffs grepped, none changes the shortcut guard); 21 hits read, all same_bug false). Not fixed: upstream main 49483e82b35 (2026-10-26) keeps the guard without a timeout check; open #16847 only widens the shortcut's ints to long. Closest items, not reports: #13036 / #12644 (introduced the shortcut; no comment or review mentions timeouts), #11188 / LUCENE-10151 and #927 (timeout design: timed-out results are partial results; #11188 item 2 is LFV-077), #12275, #11677, #16001. Gaps: 4 REST searches hit GitHub's search rate limit and were re-run as GraphQL, 1 web query was throttled and 5 planned web queries were not run
Lucene commit tested
main 645d4fe14a20 (2026-10-28; count() and the classes it uses are unchanged on upstream main 2f424fd428da, 2026-10-30): the repro through gradle in a fresh scratch worktree, stock with assertions on and off, and with fix A together with TestBooleanQuery, TestTimeLimitingBulkScorer, TestIndexSearcher and the two vector-similarity suites (80 tests) and the format check. The repro also ran on the release jars of every release from 9.2.0 to 10.6.0 (SHA-1 checked). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:491-494 (at ea57360912ed) (count() javadoc 'Count how many documents match the given query. May be faster than counting number of hits by collecting all matches'), :495-529 (count(): rewrite into ConstantScoreQuery, unwrap, the shortcut guard at :507-509 checks instanceof BooleanQuery, reader.hasDeletions() == false and isTwoClausePureDisjunctionWithTerms(), no timeout check; :513-514 max() when one term count is 0; :516-518 'return countTerm1 + countTerm2 - count(queries[2])' when min/max < 0.1), :89-94 (queryTimeout, 'partialResult' field), :613 (setTimeout), :647-650 (timedOut() 'Returns true if any search hit the timeout'), :846-857 (searchLeaf wraps the BulkScorer in TimeLimitingBulkScorer and catches TimeExceededException, setting partialResult = true)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:170 (at ea57360912ed) (isTwoClausePureDisjunctionWithTerms), :181 (rewriteTwoClauseDisjunctionWithTermsForCount)
  • lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:167-231 (at ea57360912ed) (count and reqCount: a conjunction of two term clauses with counts in [1, numDocs) returns -1, so the intersection is collected)
  • lucene/core/src/java/org/apache/lucene/search/TimeLimitingBulkScorer.java:70-87 (at ea57360912ed) (shouldExit() checked between growing windows, TimeExceededException thrown at :80-81)
  • lucene/core/src/java/org/apache/lucene/search/TermQuery.java:255-264 (at ea57360912ed) (exact docFreq count without deletions, never reaches TimeLimitingBulkScorer)
  • lucene/core/src/test/org/apache/lucene/search/BaseExplicitVectorSimilarityQueryTestCase.java:518, (at ea57360912ed) :524 ('Expect no results with the timeout' count == 0; 'Expect partial results' 0 < count < numDocs); BaseVectorSimilarityQueryTestCase.java:637, :643; BaseKnnVectorQueryTestCase.java:1047, :1053; TestTimeLimitingBulkScorer.java:62 ('Partial result and is aborted is true')
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2690 (at ea57360912ed) (the test framework installs only a never-firing timeout, setTimeout(() -> false))
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:89, :94, :490-529, :613, :647-650, :845-858 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/TermQuery.java:255-264; TotalHitCountCollector.java:47-52; BooleanWeight.java:167, :206-230; BooleanQuery.java:165-199; TimeLimitingBulkScorer.java:70-86 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84, :89, :506-518, :647-650; LRUQueryCache.java:920-925 (a cache hit returns the cached count) (at 645d4fe14a20); lucene/CHANGES.txt:343 (GITHUB#14187); lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParserBase.java:78-81 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:176 (MinSegmentSizePredicate(10000), skipCacheFactor 10), :190-196 (maxDoc < 10000, then maxDoc * 2 > average docs per leaf) and UsageTrackingQueryCachingPolicy.java:122-137 (at releases/lucene/10.5.2)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:94, :490-494, :647-650 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/search/BaseExplicitVectorSimilarityQueryTestCase.java:518, :524; BaseVectorSimilarityQueryTestCase.java:637, :643; TestTimeLimitingBulkScorer.java:62; TestBooleanQuery.java:961-1097 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/LuceneTestCaseParent.java:2690 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:507-518, :525-528, :647-650; BooleanWeight.java:167-200 (at 645d4fe14a20)

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

Repro files

About the diagrams

A summary card (PNG), made in parallel with this page from the code at that commit and an executed value probe on the stock jars and with the fix.

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.