To speed up a top-k search, DisjunctionMaxQuery sorts its clauses by best possible score so it can ignore weak ones when it skips ahead. The sort key calls getMaxScore, which may throw IOException; a Java lambda cannot throw it, so the comparator wraps it in RuntimeException. The same call four lines after the sort throws plain IOException.
getMaxScore calls: the failure inside the clause sort comes out as RuntimeException, the same failure right after the sort as the declared IOException. Select the still to open it at full size. Generated with AI, checked against the code.- You write a custom query whose scorer's
getMaxScorecan throwIOException(it reads data there), index 1 document, and runsearcher.search(new DisjunctionMaxQuery(List.of(new MatchAllDocsQuery(), custom), 0.1f), 10). - For a top-10 search Lucene first sorts the 2 clauses by their best possible score,
getMaxScore(NO_MORE_DOCS), so it can later ignore weak clauses when it skips ahead. Your clause throws during that sort. - You expect the
IOException:getMaxScoreandIndexSearcher.searchboth declare it, and the same failure one call later, right after the sort, does arrive asIOException. - Instead
searchthrowsjava.lang.RuntimeException: java.io.IOException: getMaxScore failed. The sort's comparator is a lambda, which cannot throw a checked exception, so it wraps it; acatch (IOException e)misses it.
Only applications or plugins whose own extension code can throw IOException from a clause's getMaxScore, used under a DisjunctionMaxQuery or a LogOddsFusionQuery in a top-k search (a top-level DisjunctionMaxQuery with tie-breaker 0 is spared), and that catch IOException by type. No Lucene scorer throws there. The search still fails, with the IOException as the cause. Priority Low.
Mechanism, terms and numbers557 words
For a top-k search (ScoreMode.TOP_SCORES), DisjunctionMaxScorer and LogOddsFusionScorer build a DisjunctionScoreBlockBoundaryPropagator, whose constructor sorts the clause scorers by getMaxScore(NO_MORE_DOCS). The sort key is a lambda, so it catches the IOException that getMaxScore may throw and rethrows it as new RuntimeException(e) (DisjunctionScoreBlockBoundaryPropagator.java:40 on main). The constructor itself declares IOException and, four lines after the sort, makes the identical call without wrapping, so the type the caller sees depends only on whether the failure happens during the sort or right after it. IndexSearcher.search then throws an unchecked RuntimeException with the IOException as its cause, and code that catches IOException misses it. No Lucene scorer does I/O in getMaxScore(NO_MORE_DOCS), so only custom extension code reaches it (a custom scorer, value source or postings format); a top-level DisjunctionMaxQuery with tie-breaker 0 is spared, one nested among several scoring clauses of a BooleanQuery is not; no result is wrong. The wrap came with LUCENE-8922 (8.3.0); 8.2.0 surfaced the IOException. The fix reads the max scores before the sort.
getMaxScore(upTo): aScorermethod that returns an upper bound of the scores it can produce up to docupTo; it declaresIOException(Scorer.java:79). Top-k search uses these bounds to skip documents and clauses that cannot make the top k.DisjunctionScoreBlockBoundaryPropagator: a helper thatDisjunctionMaxScorer(DisjunctionMaxScorer.java:52-53) andLogOddsFusionScorer(LogOddsFusionScorer.java:103-104) build only inTOP_SCORESmode. It sorts the clauses bygetMaxScore(NO_MORE_DOCS)so it can stop looking at clauses whose best score is below the current threshold.
// DisjunctionScoreBlockBoundaryPropagator.java:34-43
private static final Comparator<Scorer> MAX_SCORE_COMPARATOR =
Comparator.comparing(
(Scorer s) -> {
try {
return s.getMaxScore(DocIdSetIterator.NO_MORE_DOCS);
} catch (IOException e) {
throw new RuntimeException(e); // :40
}
})
.thenComparingLong(s -> s.iterator().cost());
// :49-60, the constructor declares IOException
for (Scorer scorer : this.scorers) scorer.advanceShallow(0); // :52 IOException passes
Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR); // :54 becomes RuntimeException
maxScores[i] = this.scorers[i].getMaxScore(DocIdSetIterator.NO_MORE_DOCS); // :58 passes (loop)IndexSearcher.search(q, 10)asksDisjunctionMaxQuery's scorer supplier for a scorer (DisjunctionMaxQuery.java:158); inTOP_SCORESmodeDisjunctionMaxScorerbuilds the propagator (DisjunctionMaxScorer.java:53).- The propagator's
Arrays.sort(:54) calls the comparator, which calls the custom clause'sgetMaxScore; it throwsIOException("getMaxScore failed"), and the lambda rethrows it asRuntimeException(:40). - The
RuntimeExceptiontravels up throughIndexSearcher.searchunchanged; theIOExceptionis its cause.
Controls from the hunt's probe and the fact check's probe (main, assertions off): a scorer that fails only on its 2nd getMaxScore call gets java.io.IOException at :58; one that fails in advanceShallow gets IOException at :52; a COMPLETE (non-pruning) search builds no propagator and returns 1 hit; LogOddsFusionQuery gets the same RuntimeException at :40.
Which DisjunctionMaxQuery searches build it. ScorerSupplier.get builds DisjunctionMaxScorer for any tie-breaker (DisjunctionMaxQuery.java:153-159). Only bulkScorer() avoids it: with tie-breaker 0 in TOP_SCORES mode it returns a DisjunctionMaxBulkScorer (:162-170), so a top-level tie-breaker-0 query never builds the propagator. A BooleanQuery gives its scoring clauses its own score mode (BooleanWeight.java:57-59) and calls their get() (BooleanScorerSupplier.java:426, :540). Executed on main and 10.6.0: BooleanQuery(SHOULD dmq(tie 0), SHOULD MatchAllDocsQuery) and BooleanQuery(MUST dmq(tie 0), MUST MatchAllDocsQuery), top-10, throw the same RuntimeException from :40; the same dmq(tie 0) at top level returns 1 hit, and so does BooleanQuery(MUST dmq(tie 0), FILTER MatchAllDocsQuery), which rewrites to that top-level query (BooleanQuery.java:388-392, :279-288).
Exposure and likelihood239 words
- Who is hit. Code whose own extension does I/O in a clause's
getMaxScore(NO_MORE_DOCS)(for example a custom scorer that bounds scores from doc values) and an error handler that catchesIOExceptionto fail a segment or shard cleanly: the failure arrives as an uncheckedRuntimeExceptionand bypasses that handler. - Reach. A
DisjunctionMaxQuerywith tie-breaker > 0, or with any tie-breaker as one of several scoring clauses of aBooleanQuery(executed); a top-level one with tie-breaker 0 usesDisjunctionMaxBulkScorerand is spared.LogOddsFusionQuery(10.5.0+) is hit too. - Which extension code. A custom
Query/Scorer(the repro). By the samethrows IOExceptionsignatures, also a customDoubleValuesSourceunderFunctionScoreQuery, whose scorer'sgetMaxScorecallsDoubleValues.getMaxScore(FunctionScoreQuery.java:262-264inlucene/queries;DoubleValues.java:59), or a postings format whoseImpactsSource.getImpactsreads lazily (TermScorer.java:122-123,MaxScoreCache.java:96-109,index/ImpactsSource.java:49). Only the first was run. - Default vs opt-in. Default searcher, codec and directory;
DisjunctionMaxQueryis a stock query,LogOddsFusionQueryexists from 10.5.0. The trigger is the custom code, a legal public extension point (getMaxScoredeclaresIOException). No scale is needed: 1 document, 2 clauses. - Stock Lucene never reaches it. The in-tree impacts sources answer
getMaxScore(NO_MORE_DOCS)from impacts thatadvanceShallowalready decoded; composite scorers only delegate; other leaf scorers return constants. - Loud, contained. The search fails either way; only the outer exception type is wrong. No wrong or partial results.
- Likelihood: contrived. No public report of it was found since 8.3.0 (2019).
One JUnit test, single thread, deterministic: one empty document, a DisjunctionMaxQuery (tie-breaker 0.1) of MatchAllDocsQuery and a custom query whose scorer's getMaxScore throws IOException, and search(q, 10). It fails with assertions on and off on main and on the 10.6.0 jars.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;
import java.io.IOException;
import java.util.List;
import 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 TestDisjunctionMaxQueryMaxScoreIOException extends LuceneTestCase {
public void testGetMaxScoreIOExceptionIsPropagated() throws Exception {
Query failing =
new Query() {
@Override
public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) {
return new ConstantScoreWeight(this, boost) {
@Override
public ScorerSupplier scorerSupplier(LeafReaderContext context) {
DocIdSetIterator it = DocIdSetIterator.all(context.reader().maxDoc());
Scorer scorer =
new Scorer() {
@Override
public int docID() {
return it.docID();
}
@Override
public DocIdSetIterator iterator() {
return it;
}
@Override
public float score() {
return boost;
}
@Override
public float getMaxScore(int upTo) throws IOException {
throw new IOException("getMaxScore failed");
}
};
return new ScorerSupplier() {
@Override
public Scorer get(long leadCost) {
return scorer;
}
@Override
public long cost() {
return it.cost();
}
};
}
@Override
public boolean isCacheable(LeafReaderContext ctx) {
return false;
}
};
}
@Override
public String toString(String field) {
return "failing";
}
@Override
public void visit(QueryVisitor visitor) {}
@Override
public boolean equals(Object other) {
return this == other;
}
@Override
public int hashCode() {
return System.identityHashCode(this);
}
};
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
Query q = new DisjunctionMaxQuery(List.of(new MatchAllDocsQuery(), failing), 0.1f);
// expected: the IOException from getMaxScore; actual: RuntimeException wrapping it
expectThrows(IOException.class, () -> new IndexSearcher(reader).search(q, 10));
}
}
}
}
cd $LUCENE
cp $REPRO/TestDisjunctionMaxQueryMaxScoreIOException.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
- Expected
search(q, 10)throws the clause'sjava.io.IOException: getMaxScore failed.- Observed
- It throws
java.lang.RuntimeException: java.io.IOException: getMaxScore failed, fromDisjunctionScoreBlockBoundaryPropagator.java:40.
More files, environment, full output173 words
The custom query implements ScorerSupplier directly; its Weight extends ConstantScoreWeight, which is tagged @lucene.internal (ConstantScoreWeight.java:22-29), as is usual for a test in lucene/core. A variant that uses only public API (a plain Weight subclass with its own ScorerSupplier, the cross-model reviewer's own test) fails the same way on 5adff78bb168, assertions on and off. The custom query's scorer iterates all documents with a constant score and throws IOException("getMaxScore failed") from getMaxScore. The test draws no random values. It belongs in lucene/core/src/test/org/apache/lucene/search/, next to TestDisjunctionMaxQuery, and passes :lucene:core:check -x test.
Regression test: this same test.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on the 10.6.0 jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
TestDisjunctionMaxQueryMaxScoreIOException > testGetMaxScoreIOExceptionIsPropagated FAILED
junit.framework.AssertionFailedError: Unexpected exception type, expected IOException but got java.lang.RuntimeException: java.io.IOException: getMaxScore failed
at org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException.testGetMaxScoreIOExceptionIsPropagated(TestDisjunctionMaxQueryMaxScoreIOException.java:108)
Caused by:
java.lang.RuntimeException: java.io.IOException: getMaxScore failed
at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.lambda$static$0(DisjunctionScoreBlockBoundaryPropagator.java:40)
at java.base/java.util.Comparator.lambda$comparing$77a9974f$1(Comparator.java:473)
...
at java.base/java.util.Arrays.sort(Arrays.java:1230)
at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.<init>(DisjunctionScoreBlockBoundaryPropagator.java:54)
at org.apache.lucene.search.DisjunctionMaxScorer.<init>(DisjunctionMaxScorer.java:53)
at org.apache.lucene.search.DisjunctionMaxQuery$DisjunctionMaxWeight$1.get(DisjunctionMaxQuery.java:158)
...
at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:624)
Caused by:
java.io.IOException: getMaxScore failed
at org.apache.lucene.search.TestDisjunctionMaxQueryMaxScoreIOException$1$1$1.getMaxScore(TestDisjunctionMaxQueryMaxScoreIOException.java:60)
at org.apache.lucene.search.DisjunctionScoreBlockBoundaryPropagator.lambda$static$0(DisjunctionScoreBlockBoundaryPropagator.java:38)Scorer.getMaxScore,IndexSearcher.searchand the propagator's own constructor all declareIOException.- The identical
getMaxScorecall right after the sort throwsIOExceptionunchanged; only the copy inside the lambda wraps it. - 8.2.0 surfaced the
IOException; the wrap came with LUCENE-8922, and no javadoc, CHANGES entry or test mentions it.
The full argument182 words
- The declared contract.
Scorer.getMaxScore(int)declaresIOException(Scorer.java:79), as doIndexSearcher.search(Query, int)(IndexSearcher.java:623) and the propagator's constructor (DisjunctionScoreBlockBoundaryPropagator.java:49). - Same method, two answers. The constructor lets
advanceShallow(:52) and the identicalgetMaxScore(NO_MORE_DOCS)call at:58throwIOExceptionunchanged; only the copy inside the comparator wraps it. A lambda cannot throw a checked exception, so the comparator had to adapt it somehow; nothing in the introducing PR, the javadoc or the tests suggests that changing the type was meant (an inference from the record). - History. Before the class existed, 8.2.0 surfaced the same failure as the declared
IOException(thrown viaDisjunctionMaxScorer.getMaxScore). The introducing PR (apache/lucene-solr#791) and its issue (#9965) never discuss exceptions; no javadoc, CHANGES entry or test documents the wrap. - Project practice. #12516 (its CHANGES entry: "Unwrap and throw execution exceptions cause when performing concurrent search") made concurrent search throw the original exception, as the sequential path does.
lucene/MIGRATE.md:526-527asks forUncheckedIOExceptionfor I/O left inside reader getters that no longer declareIOException(#16057): a precedent for the unchecked wrapper type, not a project-wide rule.
The devil's-advocate check132 words
The case for "intended": wrapping a checked exception in a Comparator is a common Java idiom; Query, Weight and Scorer are expert APIs; core wraps IOException in RuntimeException elsewhere (for example DirectReader's LongValues.get, util/packed/DirectReader.java:199-205); the cause is kept and the failure is loud. So this is at most a cleanup, and #12516 itself was filed under "Changes in runtime behavior", not "Bug Fixes".
Why it fails as an intent argument: those other wraps sit behind signatures that cannot throw IOException; here the enclosing constructor declares it and already throws it for the same call (:58). The argument bounds the value of a fix, not whether the behaviour is intended. The cross-model reviewer (OpenAI GPT-5.6) rated it moderate; every checker agreed it limits the priority only.
Recommended Read the max scores before sorting, sort by the stored values
Read each clause's max score once, before the sort, and sort by the stored values (as MaxScoreBulkScorer does). The IOException then propagates as declared, and the sort no longer calls getMaxScore for each comparison. Validated: the repro passes with assertions on and off; 4 disjunction suites (65 tests) and :lucene:core:check -x test pass.
All options and trade-offs2 options ยท 481 words
Both options were validated with gradle in a clean scratch worktree of 5adff78bb168 (OpenJDK 25.0.3, seed DEADBEEF, 2026-11-03): stock, the repro fails with assertions on and off; with either fix it passes, on and off.
Read the max scores before sorting, sort by the stored values (validated by the checks) (recommended)
Replace MAX_SCORE_COMPARATOR with a loop that reads getMaxScore(NO_MORE_DOCS) once per clause into maxScores, then sort the scorers and their scores together with an InPlaceMergeSorter: by Float.compare of the stored score, then by cost, as today (both sorts are stable, so the order is unchanged). getMaxScore runs outside any lambda, so its IOException propagates as declared. The Arrays and Comparator imports go.
Scorer[] scorerArray = scorers.toArray(Scorer[]::new);
for (Scorer scorer : scorerArray) {
scorer.advanceShallow(0);
}
float[] maxScoreArray = new float[scorerArray.length];
for (int i = 0; i < scorerArray.length; ++i) {
maxScoreArray[i] = scorerArray[i].getMaxScore(DocIdSetIterator.NO_MORE_DOCS);
}
new InPlaceMergeSorter() {
@Override
protected int compare(int i, int j) {
int cmp = Float.compare(maxScoreArray[i], maxScoreArray[j]);
if (cmp == 0) {
cmp = Long.compare(scorerArray[i].iterator().cost(), scorerArray[j].iterator().cost());
}
return cmp;
}
@Override
protected void swap(int i, int j) {
ArrayUtil.swap(scorerArray, i, j);
float tmp = maxScoreArray[i];
maxScoreArray[i] = maxScoreArray[j];
maxScoreArray[j] = tmp;
}
}.sort(0, scorerArray.length);
this.scorers = scorerArray;
this.maxScores = maxScoreArray;Trade-offs
A slightly larger diff than the alternative (+29/-20 lines against +7/-2). It changes how often and when getMaxScore is called: once per clause, before the sort, instead of during the sort (in each comparison the clause takes part in) plus once more after it; the regression test covers the exception path. No benchmark was run.
What the checks validated
- Stock
5adff78bb168: the repro fails through gradle with assertions on and off; with this fix it passes, on and off. - With the fix, the repro plus
TestDisjunctionMaxQuery,TestLogOddsFusionQueryandTestDisjunctionScoreBlockBoundaryPropagator: 65 tests, 0 failures, 0 skipped. :lucene:core:check -x test(format, lint, forbidden APIs) with the fix and the test: passes.
Throw UncheckedIOException in the comparator, rethrow its cause around the sort (validated by the checks)
The smallest change: in the lambda, throw new UncheckedIOException(e) instead of new RuntimeException(e), and wrap Arrays.sort in try { ... } catch (UncheckedIOException e) { throw e.getCause(); }.
- throw new RuntimeException(e);
+ throw new UncheckedIOException(e);
...
- Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR);
+ try {
+ Arrays.sort(this.scorers, MAX_SCORE_COMPARATOR);
+ } catch (UncheckedIOException e) {
+ throw e.getCause();
+ }Trade-offs
Keeps the comparator and its repeated getMaxScore calls. An UncheckedIOException that a scorer itself throws during the sort would also be rethrown as its checked cause, a small behaviour change of its own; the recommended fix has no such collision.
What the checks validated
- The repro passes through gradle with assertions on and off; the repro plus the same 3 disjunction suites: 65 tests, 0 failures (
:lucene:core:checknot run for this option). The hunt's own runs of this change agree (63 and 64 tests in their suite runs).
Downstream products are affected only if they run their own or plugin extension code (a scorer, value source or postings format) that does I/O in getMaxScore under these queries and handle IOException and RuntimeException differently; no query or document can trigger it.
A proof-obligation audit of DisjunctionScoreBlockBoundaryPropagator's contract asked whether every IOException its constructor can meet reaches the caller as declared, and found the one inside the sort comparator does not. A JUnit test then witnessed it on main; independent checks reproduced it, two of them with their own tests.
The method in full116 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The obligation: the constructor declares
IOException, so anIOExceptionfrom any scorer call it makes should reach the caller asIOException.advanceShallow(:52) and the post-sortgetMaxScore(:58) meet it; the comparator'sgetMaxScore(:38-40) does not. - A JUnit test witnessed it on main (assertions on and off) and on 10.6.0 and 10.0.0. An independent reproducer and an executed review by another model family (OpenAI GPT-5.6) wrote their own tests from the claim alone; a devil's-advocate verifier and the adjudication re-ran the finder's test; the one-test repro shipped here was then cut to its minimum.
No prior report: three scripted prior-art runs, 31 manual GitHub searches and 11 web searches over issues, PRs, commits, the mailing lists, JIRA and downstream projects, plus the changed files of all open PRs (2026-11-03), rechecked the same day.
Searches3 searches
No report found in the documented searches: zero hits for DisjunctionScoreBlockBoundaryPropagator or MAX_SCORE_COMPARATOR in apache/lucene issues and PRs, and none reports a top-k DisjunctionMaxQuery or LogOddsFusionQuery search throwing RuntimeException instead of IOException. Related, not this bug: #9965 / apache/lucene-solr#791 (LUCENE-8922, introduced the class and the wrap without discussing it), #13795 and #16291 (edit only the cost tie-break), open PR #16410 (removes LogOddsFusionScorer's use of the propagator, which would leave only the DisjunctionMaxQuery path, without mentioning the wrap), open PR #15659 (touches only DisjunctionMaxBulkScorer), elastic/elasticsearch#130239 (another bug, #14857 / #14858); background #12516 and #8851 (LUCENE-7800). Gap: GitHub search does not index PR review comments; the issues@ list, which mirrors them, was searched instead.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; JIRA archive; web | 2026-11-03 | three scripted prior-report search runs (51 queries each), 31 manual GitHub searches, 11 web searches, 9 direct reads | only the related items above |
| Open PRs | 2026-11-03 | the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01 | none changes the propagator; #16410 removes the LogOddsFusionScorer use only |
| Recheck (GitHub) | 2026-11-03 | class, method and symptom queries; PRs updated since 2026-11-01; upstream file history | nothing new; upstream main 1c2072f0284 has the identical file |
Introduced by LUCENE-8922 (1ea8419336a, 2019-07-24), which created the propagator with the wrapping comparator; first released in 8.3.0. Executed on sampled releases with equivalent tests: 8.2.0 passes; 8.3.0 to 10.6.0 and main fail. LogOddsFusionQuery shares the path from 10.5.0. Not fixed upstream.
Versions tested6 rows
Introduced by 1ea8419336a, "LUCENE-8922: Better impacts for DisjunctionMaxQuery. (#791)" (Adrien Grand, 2019-07-24; PR apache/lucene-solr#791, issue LUCENE-8922 = #9965). It created DisjunctionScoreBlockBoundaryPropagator with the wrapping comparator and made DisjunctionMaxScorer build it in TOP_SCORES mode; git blame -w -M on 5adff78bb168 gives the try/catch/throw (:37-41) to it. 8.x shipped the branch_8x cherry-pick 967e1c82be9. git tag --contains puts the wrap in every tagged release from 8.3.0 to 8.8.1 and from 9.0.0 to 10.6.0 (the history clone has no tags for 8.8.2 to 8.11.4). The version runs used equivalent tests: the finder's earlier test, its copy written against the older Scorer(Weight) API before 10.0, or the independent reproducer's test (10.5.2); the shipped test itself ran on main and 10.6.0. Later edits to the file (a reformat, #13795, #16291) did not touch the catch. LogOddsFusionScorer builds the same propagator from its first release, 10.5.0 (read). Upstream main 1c2072f0284 (2026-11-02) has the identical file.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.2.0 | last before | the IOException reaches the caller (thrown via DisjunctionMaxScorer.getMaxScore) |
| 1ea8419336a (LUCENE-8922) | introduced | adds the propagator and the wrapping comparator (read) |
| 8.3.0 | first: 8.3.0 | RuntimeException(IOException) at DisjunctionScoreBlockBoundaryPropagator.java:40 |
| 8.11.4, 9.0.0, 9.12.3 | affected | fail, same line (API copy of the test) |
| 10.0.0, 10.5.2, 10.6.0 | affected | fail, same line |
| main 5adff78bb168 | affected | fail, assertions on and off; passes with either fix |
An independent reproducer, an executed review by another model family (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication all rated it real; the adjudication confirmed it (medium confidence, priority Low). This doc's gradle runs reproduced it on main and validated both fixes.
Checks and verdicts281 words
The cross-model review ran on main ea57360912ed, whose relevant files equal 5adff78bb168's, and its test was re-run on 5adff78bb168 with the same result; every other check ran on 5adff78bb168 or a release.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | One-test JUnit fails on main (on, off), 10.6.0 and 10.0.0 at :40; its probe ran the controls (2nd-call failure, advanceShallow failure, COMPLETE mode, LogOddsFusionQuery). |
| independent reproducer | real, Low | Own test (bare Scorer subclass) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3, always at :40. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own public-API 3-test JUnit: the first-call failure gets RuntimeException, both controls behave; with the UncheckedIOException fix 3 of 3 pass; intended-behaviour argument rated moderate. |
| devil's-advocate verifier | real on intent, medium | Re-ran the finder's test on main (on, off); built the "common idiom, expert API" case and showed every other core wrap sits behind a signature that cannot throw; no in-tree trigger. |
| adjudication | confirmed (medium); Low | Re-ran the test with a new seed, on and off: fail; every argument against is about reach, not intent. |
| separate re-run, audit and final repro check | reproduced; confirmed, Low | Fresh seeds on main (on, off) and the 10.6.0 jars fail; the fix passes; class lowered to minor. |
| fact check of this doc (and a second one by another model family, OpenAI GPT-5.6) | real; scope corrected | Ran the shipped test through gradle on main (on, off) and on the 10.6.0 jars (on, off): fail at :40; with the recommended fix, 65 tests pass. Its probe found the nested tie-breaker-0 reach, now in the doc. |
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 550 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,459 words
- Registry title
- A top-k search of a DisjunctionMaxQuery or LogOddsFusionQuery throws a plain RuntimeException instead of the declared IOException when a clause's Scorer.getMaxScore throws IOException, because DisjunctionScoreBlockBoundaryPropagator's sort comparator wraps it (since 8.3.0)
- Area
- core / search: disjunction scoring (DisjunctionScoreBlockBoundaryPropagator, built by DisjunctionMaxScorer and LogOddsFusionScorer for top-k searches)
- Priority
- Low, rank 839: Latent exception-type inconsistency: a top-k DisjunctionMaxQuery or LogOddsFusionQuery search (a top-level tie-breaker-0 DisjunctionMaxQuery is spared) throws a RuntimeException wrapping the IOException instead of the declared IOException when a clause's getMaxScore fails, since 8.3.0 (10.5.0 for LogOddsFusionQuery). No in-tree scorer does I/O there, so only custom extension code (such as a custom scorer) triggers it, and the search still fails with the IOException as the cause.
- Impact class
- minor
- Impact note
- Only applications that put a custom or plugin Query whose Scorer.getMaxScore(NO_MORE_DOCS) can throw IOException (for example one that reads data there) under a DisjunctionMaxQuery (tie-breaker > 0 at top level, any tie-breaker next to another scoring clause of a BooleanQuery) or a LogOddsFusionQuery, in a top-k (TOP_SCORES) search with the default searcher, are hit, with -ea and -da and no scale: when that call fails during the clause sort, IndexSearcher.search throws an unchecked RuntimeException with the IOException as its cause instead of the declared IOException, so code that catches IOException to fail a segment or shard cleanly misses it. The search still fails loudly, no result is wrong, and no in-tree scorer does I/O in getMaxScore(NO_MORE_DOCS) (impacts are already decoded by advanceShallow), so stock Lucene never reaches it. (the final repro check lowered the class from wrong error to minor: latent, only custom or plugin extension code can trigger it, the search still fails with the IOException as the cause, no result is wrong)
- Summary
- You run a top-k search, IndexSearcher.search(q, 10), of a DisjunctionMaxQuery with tie-breaker 0.1 whose second clause is your own query whose Scorer.getMaxScore throws IOException, and expect that IOException: Scorer.getMaxScore and IndexSearcher.search both declare it, and the same failure on a later, identical getMaxScore call does surface as IOException. Instead the search throws java.lang.RuntimeException wrapping it, from DisjunctionScoreBlockBoundaryPropagator.java:40, because the propagator sorts the clauses with a Comparator whose key extractor calls getMaxScore(NO_MORE_DOCS) and turns the IOException into a RuntimeException (a lambda cannot throw a checked exception). LogOddsFusionQuery builds the same propagator and behaves the same; a search that does not prune (COMPLETE score mode) is unaffected.
- Introduced
- 1ea8419336a6806463fd9dc83dc77e41a5475649 (master, 'LUCENE-8922: Better impacts for DisjunctionMaxQuery. (#791)', by Adrien Grand (jpountz), apache/lucene-solr#791, issue LUCENE-8922 = apache/lucene#9965); the 8.x code that shipped is the branch_8x cherry-pick 967e1c82be9 plus 8df37aaf2c5 ('LUCENE-8922: 8.x adjustments.', which does not touch the comparator). The commit created DisjunctionScoreBlockBoundaryPropagator with MAX_SCORE_COMPARATOR, whose key extractor calls getMaxScore(NO_MORE_DOCS) and does 'catch (IOException e) { throw new RuntimeException(e); }', and made DisjunctionMaxScorer build the propagator in TOP_SCORES mode [read: git blame -w -M on 5adff78bb168 gives the try/catch/throw at :37-41 to it] [exec: 8.2.0 passes, 8.3.0 fails]. Neither the PR review nor the issue comments mention the wrap. (2019-07-24 (author date = commit date, 15:12:57+02:00; PR merged 13:12:58Z); first release 8.3.0 (tagged 2019-10-24, LUCENE-8922 listed under 8.3.0 Optimizations))
- First affected version
- 8.3.0 [exec] (8.2.0 has no DisjunctionScoreBlockBoundaryPropagator, and the same program gets the IOException there, thrown via DisjunctionMaxScorer.getMaxScore [exec]: a change of behaviour that first shipped in 8.3.0)
- Affected versions
- every release from 8.3.0 through 10.6.0 (4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT) through DisjunctionMaxQuery, and through LogOddsFusionQuery from its first release, 10.5.0 [read: releases/lucene/10.5.0 and 10.6.0 LogOddsFusionScorer.java:104]. Executed and failing with RuntimeException(IOException) at DisjunctionScoreBlockBoundaryPropagator.java:40: 8.3.0, 8.11.4, 9.0.0 and 9.12.3 with a copy of the test written against the pre-10.0 Scorer(Weight) API (main() mode, -ea), 10.0.0 and 10.6.0 with the finder's JUnit test, 10.6.0, 10.5.2 and 9.12.3 with the independent reproduction's own test, main with -ea and -da (finder, the independent reproduction, the intended-behaviour check, adjudication, registration check) and the released 10.6.0 jars with -da (registration check) [exec]. By git tag --contains, the branch_8x commit is in all 14 releases/lucene-solr tags 8.3.0..8.8.1 of the history clone and 1ea8419336a in all 34 releases/lucene tags 9.0.0..10.6.0 [read]; 8.8.2..8.11.3 have no tag there, the fix site is unchanged since 2019 apart from a reformat and two cosmetic edits to the secondary cost comparator, and 8.11.4 fails [exec]. Upstream main 1c2072f0284 (2026-11-02) has the same file blob (ba9827d20cc) [read]. Not affected: 8.2.0 and earlier (8.2.0 executed: pass). Not new in 10.6.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: proof-obligation audit
- Upstream and prior reports
- none found (2026-11-03, on top of the finder's run and the intended-behaviour check's and the adjudication's searches; rechecked the same day): 204 recorded queries (three scripted prior-report searches of 51 queries each over GitHub issues and PRs, commits, downstream projects, dev@, java-user@, issues@, commits@, the JIRA archive and the web; 31 manual GitHub searches; 11 web searches; 9 direct reads of items, the JIRA archive, the list archive and the git history), the changed files of all 409 open apache/lucene PRs and of the 42 PRs updated since 2026-11-01 (re-checked), and the upstream commit history of the file with author and committer dates. Zero hits for DisjunctionScoreBlockBoundaryPropagator or MAX_SCORE_COMPARATOR in apache/lucene issues and PRs; no hit reports the wrap or its symptom. Closest, not this bug: #9965 / LUCENE-8922 and apache/lucene-solr#791 (introduced the class and the wrap; exceptions never discussed), #13795 and #16291 (edit only the secondary cost comparator), open PR #16410 by jaepil (removes LogOddsFusionScorer's use of the propagator, which would narrow the trigger to DisjunctionMaxQuery, but neither reports nor fixes the wrap), open PR #15659 (touches only DisjunctionMaxBulkScorer), elastic/elasticsearch#130239 (a stack frame in the propagator's advanceShallow; another bug, #14857 / #14858); background: #12516 and #8851 / LUCENE-7800. Not fixed: upstream main 1c2072f0284 (2026-11-02) has the identical file blob.
- Lucene commit tested
- main
5adff78bb168(the one-test repro through gradle in a clean scratch worktree, assertions on and off, unfixed and with each of the two fixes; with either fix the repro,TestDisjunctionMaxQuery,TestLogOddsFusionQueryandTestDisjunctionScoreBlockBoundaryPropagatorpass, and:lucene:core:check -x testpasses with the recommended one, 2026-11-03); the released 10.6.0 jars (the same test, assertions on and off, in the doc's fact check). Earlier checks ran the same or equivalent tests on 8.2.0 (pass) and 8.3.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main (fail); the cross-model review ran on mainea57360912ed, whose relevant files equal5adff78bb168's, and was re-run on5adff78bb168.
- Finding record
lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-43 (at 5adff78bb168) (MAX_SCORE_COMPARATOR: getMaxScore(NO_MORE_DOCS) in the key extractor, 'throw new RuntimeException(e)' at :40), :49 (constructor declares IOException), :52 (advanceShallow, IOException passes), :54 (Arrays.sort with the comparator), :58 (the identical getMaxScore(NO_MORE_DOCS) call, IOException passes)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:52-53 (at 5adff78bb168) and LogOddsFusionScorer.java:103-104 (both build the propagator when scoreMode == TOP_SCORES)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:148-171 (at 5adff78bb168) (ScorerSupplier.get builds DisjunctionMaxScorer, :158; bulkScorer uses DisjunctionMaxBulkScorer, without the propagator, only when tieBreakerMultiplier == 0 and TOP_SCORES, :163-170)lucene/core/src/java/org/apache/lucene/search/Scorer.java:71 (at 5adff78bb168) (advanceShallow throws IOException), :79 (getMaxScore throws IOException); IndexSearcher.java:623 (search(Query, int) throws IOException); Weight.java:202-207 (DefaultScorerSupplier, @lucene.internal)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-43 (at 5adff78bb168) (the comparator, throw at :40), :49-60 (constructor; :52 advanceShallow, :54 Arrays.sort, :58 the identical getMaxScore call)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxScorer.java:52-53; (at 5adff78bb168) LogOddsFusionScorer.java:103-104 (built only in TOP_SCORES)lucene/core/src/java/org/apache/lucene/search/DisjunctionMaxQuery.java:153-159 (at 5adff78bb168) (ScorerSupplier.get builds DisjunctionMaxScorer for any tie-breaker), :162-170 (bulkScorer: DisjunctionMaxBulkScorer only for tie-breaker 0 in TOP_SCORES); BooleanWeight.java:57-59 (scoring clauses get the parent's score mode); BooleanScorerSupplier.java:426, :540 (child get()); BooleanQuery.java:388-392 (drops a MatchAllDocsQuery FILTER next to a MUST), :279-288 (1-clause rewrite)lucene/core/src/java/org/apache/lucene/search/Scorer.java:71 (at 5adff78bb168) (advanceShallow throws IOException), :79 (getMaxScore throws IOException)
- Who is affected
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:55 (at 5adff78bb168) (public constructor taking a DoubleValuesSource), :262-264 (getMaxScore calls scores.getMaxScore); lucene/core/src/java/org/apache/lucene/search/DoubleValues.java:59; TermScorer.java:122-123; MaxScoreCache.java:96-109; lucene/core/src/java/org/apache/lucene/index/ImpactsSource.java:49
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Scorer.java:79; (at 5adff78bb168) IndexSearcher.java:617-624; DisjunctionScoreBlockBoundaryPropagator.java:49-60lucene/CHANGES.txt:3466-3469 (at 5adff78bb168) (GITHUB#12516 under 'Changes in runtime behavior')lucene/MIGRATE.md:517-527 (at 5adff78bb168) (GITHUB#16057: UncheckedIOException for I/O left inside cheap reader getters that no longer throw)lucene/core/src/java/org/apache/lucene/util/packed/DirectReader.java:199-205 (at 5adff78bb168) (a wrap behind LongValues.get, which cannot throw)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/MaxScoreBulkScorer.java:487-492 (at 5adff78bb168) (sorts by stored maxWindowScore / cost)
- Diagrams
lucene/core/src/java/org/apache/lucene/search/DisjunctionScoreBlockBoundaryPropagator.java:34-60 (at 5adff78bb168)
- TestDisjunctionMaxQueryMaxScoreIOException.java (one test, lucene/core):
repro/LFV-1222/TestDisjunctionMaxQueryMaxScoreIOException.java(112 lines)
Made in parallel with this doc from the code at 5adff78bb168 and the executed repro, and re-rendered after the fact check (scope and fix labels); the PNG is the summary still. The fix panel shows the smallest fix, because the recommended one is too long to draw verbatim; both are in Suggested fix.
assets/LFV-1222/bug.png(550 KB)
