Fix NPE explaining script_score with no sub-scorer - #22624
Conversation
ScriptScoreQuery's Weight.explain() handed the sub-query scorer to ScriptScorer without a null check. A sub-query whose weight explains a document as a match but returns no scorer for that segment therefore made explain() throw a NullPointerException, and with explain or profile enabled a single such clause failed the whole search request. scorerSupplier() already treats a null sub-query scorer as "no matches on this segment". explain() now reports no match in the same case instead of dereferencing the null scorer. Signed-off-by: kdelay <kdelay20@gmail.com>
PR Reviewer Guide 🔍(Review updated until commit f802fd9)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to f802fd9
Previous suggestionsSuggestions up to commit 771c576
|
|
❌ Gradle check result for 771c576: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
Signed-off-by: kdelay <kdelay20@gmail.com>
|
The failure in build 82896 is in That build reported 2 failed, 43005 passed, 752 skipped, and both failures are in that one suite. This suite is tracked as flaky in #16658. Querying the Gradle Check Metrics data for On the mechanism side, the diff here adds one branch to I have pushed a commit to re-run the check. |
|
Persistent review updated to latest commit f802fd9 |
|
❌ Gradle check result for f802fd9: FAILURE Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change? |
|
Build 82898 failed on a different suite than 82896: This one is already tracked as flaky in #18875 (open). Querying the Gradle Check Metrics data for On the mechanism side, the only source change here adds one branch to That makes two consecutive runs failing in two separately tracked flaky suites ( |
hyunwoo-kurly
left a comment
There was a problem hiding this comment.
Thanks for picking this up.
For context on the wider defect: the same unguarded pattern exists in FunctionScoreQuery.CustomBoostFactorWeight.explain(), where functionScorer(context) returns null for the same state and the result is dereferenced without a check. I filed #22634 and opened #22635 with a fix and a regression test for that site. It is a different file, so there is no overlap with this PR.
Two notes that may be useful while reviewing this one:
- The other half of the mismatch is a sub-query weight that explains a match while returning a scorer which is not null but does not position on the document.
advance(doc)then returns a different doc and the code continues intoassert doc == newDocandscorer.score(), so with assertions enabled it trips the assert, and without them the explanation reports the score of a different document. I left the equivalent assert alone in #22635 for the same reason, and I am handling that state as a separate change on top of both PRs. - A concrete producer of that state is the k-NN plugin's
KNNWeight.explain(). On2.19it isreturn Explanation.match(1.0f, "No Explanation");, a match for every document without consulting a scorer at all. Onmainthe radial-search path returnsExplanation.match(0.0f, ...)for a document that is not among the segment's nearest neighbors. That is the k-NN side of the disagreement, tracked in opensearch-project/k-NN#3479 with a fix in opensearch-project/k-NN#3480.
|
Thanks for the follow-up, and for filing #22634 / #22635. Agreed there is no overlap: this PR touches only On the other half of the mismatch, a sub-query weight that explains a match while returning a scorer that does not position on the document: I probed where that lands on this branch today. With a sub-query whose Past that point I am reading the code rather than reporting a measurement: That lines up with handling it as a change on top of both PRs, so I will leave the assert alone here and keep this one to the null case. The branch is still even with |
Description
ScriptScoreQuery'sWeight.explain()passes the sub-query scorer straight intoScriptScorerwithout a null check:When a sub-query's weight explains a document as a match but produces no scorer for that segment,
scorer.iterator()throwsNullPointerException: Cannot invoke "org.apache.lucene.search.Scorer.iterator()" because "this.subQueryScorer" is null. Becauseexplain()runs in the fetch phase, one such clause fails the whole search request for requests that succeed fine withexplain/profiledisabled.scorerSupplier()in the same anonymousWeightwas fixed for the equivalent case in #19650 and returnsnullthere, meaning "no matches on this segment". This change makesexplain()agree with it: a null sub-query scorer now yieldsExplanation.noMatch(...)wrapping the sub-query explanation instead of dereferencing the null scorer.Testing:
ScriptScoreQueryTests#testExplainWhenSubQueryScorerIsNullcovers the scorer/explain mismatch with a sub-query whose weight always explains a match and never returns a scorer. Reverting theexplain()guard makes it fail with the same NPE quoted above../gradlew :server:test --tests "*ScriptScore*" --tests "org.opensearch.index.query.functionscore.*" --tests "*FunctionScoreQuery*"passes, as do:server:spotlessJavaCheckand:server:forbiddenApisMain.No CHANGELOG entry: the file records that it is no longer used for release notes as of 3.6 (#21071).
Related Issues
Resolves #22619
Check List
AI tool disclosure
Following the disclosure request in opensearch-project's CONTRIBUTING guide: I used an AI coding tool, Claude Code (Anthropic Claude), to write the change and the test in this pull request. The failure mode, the reproduction and the verification runs quoted above were checked against a local build before submitting, and I can answer questions about any part of the diff.
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.