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.
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.- Index 22 documents, no deletions: term
ain docs 0-20 (21 documents), termbin docs 0-1 (2 documents, which also havea), doc 21 with neither.a OR bmatches 21 documents. - Give the searcher a timeout that has already expired,
setTimeout(() -> true), and callcount(a OR b). You expect a partial count, at most 21: collecting the same query under the same timeout gives 0, andcount()on 9.9.2 returns 0. - 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 shortcutcount(a) + count(b) - count(a AND b). count(a)= 21 andcount(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.
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.
- 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 andtimedOut()becomes true; the flag is the fieldpartialResult(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 aTermQueryis the term's docFreq (TermQuery.java:255-260).- The two-term shortcut (GITHUB#13036, 9.10.0). For
a OR bmade of exactly twoTermQuerySHOULD 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()returnscount(a) + count(b) - count(a AND b)(inclusion-exclusion,IndexSearcher.java:506-518).
- The shortcut's guard checks the query shape and
reader.hasDeletions() == false(IndexSearcher.java:507-509), never the timeout. count(a)andcount(b)are docFreq values.TotalHitCountCollector#getLeafCollectorreads them before any scorer exists (TotalHitCountCollector.java:47-52), so they are exact under any timeout.count(a AND b)cannot be read from statistics:BooleanWeight'sreqCountreturns -1 for two clauses whose counts are in [1, numDocs) (BooleanWeight.java:225-227). So it is collected with aTotalHitCountCollectorManager(IndexSearcher.java:525-528) throughsearchLeaf, which wraps the scorer in aTimeLimitingBulkScorer(:846-847). That scorer checksshouldExit()before each window and throws (TimeLimitingBulkScorer.java:80-81);searchLeafcatches it, setspartialResultand keeps the count so far (IndexSearcher.java:856-857).:518subtracts 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 withtimedOut()true). Otherwise it is too high by at most the intersection, which the 0.1 ratio keeps under 10% of the true count.
- Repro: 22 documents,
ain 21,bin 2 (both also havea), already-expired timeout:count()= 21 + 2 - 0 = 23 >maxDoc22 (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 (
ain 1,960,bin 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
BoostQueryorConstantScoreQuery, for the classicQueryParser's parse ofa b, and with the stockQueryTimeoutImpland 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 withtimedOut()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 isintarithmetic, 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-outcount(), for example as "at least N results", for a query of exactly twoTermQuerySHOULD clauses (plain, or underBoostQueryorConstantScoreQuery) 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 classicQueryParserwith 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 thiscount()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 > 1or 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
LRUQueryCachecaches 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 aBooleanQueryonly 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 untimedcount()on a shared cache, the first timed call returned the exact 21 withtimedOut()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.
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.
/*
* 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());
}
}
}
}
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 abovemaxDoc22. - Observed
count()returns 23 (= 21 + 2 - 0), with assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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=22count()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 abovemaxDoccannot 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 fieldpartialResult(: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 allsearch/countmethods", 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:518expectscount() == 0undersetTimeout(() -> true)("Expect no results with the timeout") and:524expects0 < count < numDocs("Expect partial results");BaseVectorSimilarityQueryTestCase.java:637,:643do 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,
TotalHitCountCollectorManagerand 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
IndexSearchersubclass that counts itscount()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.
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.
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
645d4fe14a20with 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 owntestTwoClauseTermDisjunctionCountOptimization),TestTimeLimitingBulkScorer(2),TestIndexSearcher(11) andTestFloatVectorSimilarityQuery/TestByteVectorSimilarityQuery(18 and 18, whosetestTimeoutchecks timed-outcount()values): 6 classes, 80 tests, 0 failures, 3 skipped, with assertions on and with-Ptests.asserts=false;:lucene:core:checkGoogleJavaFormatpasses 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.
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.
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.
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
QueryTimeoutthat fires from its n-thshouldExit()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#counton the skewed two-term SHOULD disjunctions (also underBoostQuery) 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| Prior-art tool: GitHub issues, PRs and commits (apache/lucene), downstream trackers, mailing lists, JIRA | 2026-10-30 | fresh 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 hits | no report; top hits are known context (#13202, #927, #12358, #13036, #12275, #16001) |
| GitHub GraphQL, apache/lucene and all of GitHub | 2026-10-30 | 39 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-29 | only the items that introduced the shortcut, and unrelated hits; one new related hit, #16112 |
| Downstream GitHub trackers | 2026-10-30 | 30 searches on Elasticsearch, OpenSearch and Solr; 5 items fetched and grepped | nothing about Lucene's count shortcut under a timeout |
| Open PRs | 2026-10-30 | changed files of all 408 open PRs; diffs of the 19 that touch IndexSearcher, BooleanQuery, BooleanWeight, TermQuery or ConstantScoreQuery grepped for the guard identifiers | none changes the guard; #16847 only widens the counts to long |
| Mailing lists by hand, web | 2026-10-30 | 27 list queries (dev@, java-user@, issues@) and the 10 mails of the dev@ thread; 8 web queries | no report; no maintainer statement of prior knowledge |
| Earlier searches | 2026-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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (unreleased 11.0) | affected | 23 > maxDoc 22, -ea and -da, gradle and standalone |
| 10.0.0 to 10.6.0 (13 releases) | affected | 23 (10.0.0, 10.5.2, 10.6.0 also -da) |
| 9.10.0 to 9.12.3 (7 releases) | affected | 23 (9.10.0 also -da) |
| 9.3.0 to 9.9.2 (11 releases) | not affected | pass (9.3.0 and 9.9.2 also -da); value 0 probed on 9.3.0 and 9.9.2 |
| 9.2.0 | not applicable | no IndexSearcher#setTimeout; the repro does not compile |
9.9.2 + only the c845bf02fefc diff | attribution | 23 (-ea and -da) |
9.10.0 with only the c845bf02fefc diff reversed | attribution | 0 (-ea and -da) |
10.0.0 minus only b537e1da2760's 21 count() lines | attribution | 0 |
| main + fix A | not affected | the repro passes (timed-out count 0) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Bounded-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, medium | Could not run code; intended-behaviour argument moderate and rejected. A separate run re-ran the repro: fails. |
| Independent reproducer | real | Own 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 | real | Own 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, high | Re-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 | verified | Fails 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, high | Executed: 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 | real | The 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, fixed | Re-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, fixed | Re-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 fixed | Could 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 validated | Gradle 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. |
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 493 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,373 words
- 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 ofa 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 main2f424fd428da, 2026-10-30): the repro through gradle in a fresh scratch worktree, stock with assertions on and off, and with fix A together withTestBooleanQuery,TestTimeLimitingBulkScorer,TestIndexSearcherand 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.
- 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)
- TestTimedOutCountTwoTermDisjunction.java (JUnit for Lucene's build):
repro/LFV-465/src/org/apache/lucene/search/TestTimedOutCountTwoTermDisjunction.java(59 lines)
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.
assets/LFV-465/bug.png(493 KB)
