GITHUB#15239: Honor exhaustive ScoreMode in BatchScoreBulkScorer - #16542
GITHUB#15239: Honor exhaustive ScoreMode in BatchScoreBulkScorer#16542NextbrickInc wants to merge 5 commits into
Conversation
Nested TopScoreDocCollectors can still call setMinCompetitiveScore, but COMPLETE searches must visit every match. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@jimczi , a brief follow-up when you have time: this applies the eager ScoreMode check discussed in #15239, and all CI checks are green. The regression test covers exhaustive COMPLETE collection wrapping a TopScoreDocCollector. Could you confirm whether this matches the intended fix, or suggest another search maintainer who could review it? Thank you. Pls approve, merge sir. when you get chance. |
|
Thanks @NextbrickInc, the approach looks good to me, that's what I had in mind in the issue. I checked out the branch locally: the new test does fail without the change in A few comments:
That one predates the 10.3 regression (it came with #14293) so I'm fine if you want to keep it out of scope, but if we state that an exhaustive score mode never prunes then we should be consistent. I'd rather fix it here, it's a two lines change. I checked the other The CHANGES.txt: the entry is only in the 11.0.0 section. The regression is in 10.3 so we'll want it in the 10.6.0 section as well when this gets backported. Minor nits:
Nothing blocking, the change itself is correct. Mostly want to agree on the |
|
Thanks for the thorough review — I went through every point locally and they all hold up.
Your repro lands exactly: 5000 docs, two FILTER clauses, the collector from the new test → But it isn't only Those queries look fine today only because
So I'd rather not leave it out of scope —
plus threading I also audited the rest: Javadoc — agreed, and it's measurable rather than theoretical: the same CHANGES.txt — agreed, 10.6.0 too. Worth noting the Nits — all three taken. Validation: new suite Lucene_PR16542_Technical_Review_NextBricks_Shrey_Narayan.pdf |
…r too DenseConjunctionBulkScorer aborts the remaining windows whenever minCompetitiveScore exceeds its constant score, without checking the score mode. It is reachable with an exhaustive ScoreMode from two places: BooleanScorerSupplier, for a FILTER-only conjunction, and ConstantScoreScorerSupplier, which backs MatchAllDocsQuery, FieldExistsQuery and point range queries. Without deletions the ConstantScoreScorerSupplier path is hidden by the collectRange() shortcut in scoreWindow(), which collects the whole run in one call so the check only runs once. With a single deleted document acceptDocs is non-null, the shortcut no longer applies, and a COMPLETE search for *:* over a 100k-doc segment collects 4095 documents. Thread the ScoreMode through and skip the check when it is exhaustive, the same way BatchScoreBulkScorer now does. The existing four-argument constructor is kept as a delegate that passes TOP_SCORES, so the pruning semantics of TestDenseConjunctionBulkScorer are unchanged. Also address review feedback: - ScoreMode.COMPLETE javadoc now describes the exhaustive contract and points at Scorable#setMinCompetitiveScore instead of Scorer, and no longer carries an issue number. - CHANGES.txt entry added to the 10.6.0 section for the backport. - TestMultiCollector uses the asserting searcher, and the reused Document is explained. - New TestExhaustiveScoreModeNoPruning covers both bulk scorers, the CombinedFieldQuery call site, indexes from 1k to 100k documents, and asserts that TOP_SCORES still prunes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for the quick turnaround, the I pulled the branch and reverted just Two things before this can go in:
The four-argument Then some trimming on
The javadoc rewording and the 10.6.0 CHANGES entry look good. |
jimczi
left a comment
There was a problem hiding this comment.
Same points as my previous comment, put inline so they are easier to act on.
| /** Constructor that allows dynamic pruning, for tests and callers that never collect matches. */ | ||
| DenseConjunctionBulkScorer( | ||
| List<DocIdSetIterator> iterators, | ||
| List<TwoPhaseIterator> twoPhases, | ||
| int maxDoc, | ||
| float constantScore) { | ||
| this(iterators, twoPhases, maxDoc, constantScore, ScoreMode.TOP_SCORES); |
There was a problem hiding this comment.
This default is a trap. ReadAheadMatchAllDocsQuery in the test tree already calls this constructor, and it has a scoreMode in scope two lines above in get(), so it now prunes under COMPLETE without anyone noticing. It's test-only today but it's exactly the mistake the next caller will make.
I'd rather pass the score mode explicitly everywhere and drop the delegate. The 45 call sites in TestDenseConjunctionBulkScorer are a mechanical sed.
There was a problem hiding this comment.
Agreed, removed. ScoreMode is passed explicitly everywhere now, and ReadAheadMatchAllDocsQuery passes the scoreMode it already had in scope in get() — which was exactly the bug you predicted the next caller would hit. 44 call sites in TestDenseConjunctionBulkScorer plus that one.
| w.addDocument(doc); | ||
| } | ||
| w.forceMerge(1); | ||
| w.deleteDocuments(new Term("del", "yes")); |
There was a problem hiding this comment.
This test is flaky. It fails with -Ptests.seed=14471C5085979DA7:
java.lang.AssertionError: TOP_SCORES should still prune, collected=99999
RandomIndexWriter.getReader() calls doRandomForceMerge(), which randomly runs forceMerge(1) or forceMergeDeletes(). When that happens the deleted document is expunged, acceptDocs is null again, the collectRange() shortcut in scoreWindow() collects the whole segment in one call and nothing gets pruned.
Use a plain IndexWriter here (or setDoRandomForceMerge(false)) and assert reader.hasDeletions().
There was a problem hiding this comment.
Confirmed, and thanks for the seed — your diagnosis was exactly right. Both deletion tests now use a plain IndexWriter and assert reader.hasDeletions() before the pruning assertion. Passes on -Ptests.seed=14471C5085979DA7, as does the full :lucene:core:test on that seed.
| w.addDocument(doc); | ||
| } | ||
| w.forceMerge(1); | ||
| w.deleteDocuments(new Term("del", "yes")); |
There was a problem hiding this comment.
Same RandomIndexWriter randomness as in testMatchAllWithDeletionsStillPrunesUnderTopScores below. This one keeps passing when the deletion is merged away (the assertions hold either way), but it silently stops testing the acceptDocs != null path, which is the whole point of the test. Worth asserting reader.hasDeletions() so it fails loudly instead.
There was a problem hiding this comment.
Same fix here. Worth recording that this one was passing vacuously: with the guard disabled it now fails 30/30 iterations at 4095/19999, whereas before it depended on whether doRandomForceMerge() happened to expunge the deletion. The hasDeletions() assertion makes that loud instead of silent.
| // 3. DenseConjunctionBulkScorer via ConstantScoreScorerSupplier (single constant-score clause) | ||
| // --------------------------------------------------------------------------------------------- | ||
|
|
||
| public void testConstantScoreQueryCompleteVisitsEveryMatchDup() throws Exception { |
There was a problem hiding this comment.
This duplicates testConstantScoreQueryIsUnaffected further down, same query, same index, same assertion. And the Dup suffix looks like a leftover. Can we drop one of the two?
There was a problem hiding this comment.
Leftover from an earlier iteration, removed. Kept testConstantScoreQueryIsUnaffected and folded it into the constant-score group as a regression guard on the ConstantBulkScorer wrapping.
| runCollect(searcher, q, ScoreMode.COMPLETE)); | ||
| // TOP_DOCS_WITH_SCORES is NOT exhaustive, so pruning is honored there. This documents | ||
| // the mismatch with the ScoreMode.COMPLETE javadoc, which claims setMinCompetitiveScore | ||
| // is "only honored for TOP_SCORES". |
There was a problem hiding this comment.
Stale, you removed that sentence from the ScoreMode.COMPLETE javadoc in this same commit. The assertion itself is fine, just reword the comment to say that TOP_DOCS_WITH_SCORES is not exhaustive so pruning is expected there.
There was a problem hiding this comment.
Right, I removed that sentence in this same commit. Reworded to say that TOP_DOCS_WITH_SCORES is not exhaustive, so pruning is expected there. The assertion itself is unchanged.
| * Verification harness for GITHUB#15239 / PR #16542. | ||
| * | ||
| * <p>An exhaustive {@link ScoreMode} (COMPLETE / COMPLETE_NO_SCORES / TOP_DOCS) must visit every |
There was a problem hiding this comment.
TOP_DOCS is not exhaustive, isExhaustive() returns false for it. The exhaustive modes are COMPLETE and COMPLETE_NO_SCORES.
Also I'd drop the PR number and the "verification harness" framing, this is a permanent test now, not a one-off check.
There was a problem hiding this comment.
Corrected — isExhaustive() does return false for TOP_DOCS, so the list is now COMPLETE / COMPLETE_NO_SCORES. Also dropped the PR number and the "verification harness" framing; agreed it is a permanent test, not a one-off check.
| RandomIndexWriter w = new RandomIndexWriter(random(), dir)) { | ||
| for (int i = 0; i < numDocs; ++i) { | ||
| Document doc = new Document(); | ||
| doc.add(new org.apache.lucene.document.NumericDocValuesField("n", i)); |
There was a problem hiding this comment.
Fully qualified inline here and in a few other places (IntPoint too). Let's import them.
There was a problem hiding this comment.
Imported. No fully qualified names remain outside the import block.
| // --------------------------------------------------------------------------------------------- | ||
|
|
||
| public void testTermQueryCompleteVisitsEveryMatchAtScale() throws Exception { | ||
| for (int numDocs : new int[] {1000, 5000, 20000, 65536, 100000}) { |
There was a problem hiding this comment.
These size loops don't buy much over a single size comfortably above WINDOW_SIZE, the failure mode is the same at 5k and at 100k. Same for the other numDocs loops in the file. The suite is 8.5s today and it's almost all indexing, which is a lot for what is really three or four distinct cases.
There was a problem hiding this comment.
Agreed, the failure mode is the same at 5k and at 100k. Collapsed to a single NUM_DOCS = 20_000 throughout and merged the MatchAll / FieldExists / point-range cases onto one index. Suite is 1.6s now, down from 8.5s.
One note: I landed at 10 tests rather than the three or four you suggested — the two bulk scorers, both suppliers, the deletions path and the TOP_SCORES control did not compress further without dropping a distinct case. Say the word if you would still rather see it tighter and I will merge the FILTER-only pair and fold in the CombinedFieldQuery case.
…ollow-up
Pass ScoreMode explicitly instead of defaulting it
---------------------------------------------------
The four-argument DenseConjunctionBulkScorer constructor that defaulted to
TOP_SCORES is removed. ReadAheadMatchAllDocsQuery already called it while
holding a scoreMode two lines above in get(), so it pruned under COMPLETE;
it now passes that score mode through. The 44 call sites in
TestDenseConjunctionBulkScorer pass ScoreMode.TOP_SCORES explicitly, which
preserves their existing pruning semantics.
Stop the deletions coverage from disappearing at random
-------------------------------------------------------
The two tests that need a deleted document used RandomIndexWriter, whose
getReader() calls doRandomForceMerge(). When that expunged the deletion,
acceptDocs became null again, the collectRange() shortcut in scoreWindow()
collected the whole segment in one call, and nothing was pruned:
testMatchAllWithDeletionsStillPrunesUnderTopScores failed on
-Ptests.seed=14471C5085979DA7, and testConstantScoreQueriesWithDeletions-
UnderComplete kept passing while silently no longer testing the
acceptDocs != null path. Both now use a plain IndexWriter and assert
reader.hasDeletions(). With the guard disabled the latter fails 30/30
iterations instead of intermittently.
Do not enable the asserting searcher on contract-violating collectors
---------------------------------------------------------------------
TestMultiCollector was changed to newSearcher(reader, true, true, true) in
the previous commit. AssertingWeight sets canSetMinCompetitiveScore only
for TOP_SCORES with a top level scoring clause, and AssertingScorer asserts
on it, so whenever the random draw produced an AssertingIndexSearcher the
test's deliberately contract-violating collector tripped the assert: 22
failures in 500 iterations. The original newSearcher(reader, false, false,
false) was load bearing. Both this test and the new one now use
(true, false, true), keeping reader wrapping and intra-segment concurrency
without the assertion wrapping.
Trim the new test suite
-----------------------
Drop the duplicated testConstantScoreQueryCompleteVisitsEveryMatchDup,
collapse the {1000, 5000, 20000, 65536, 100000} loops to a single
NUM_DOCS = 20000 comfortably above WINDOW_SIZE, and merge the MatchAll,
FieldExists and point range cases onto one index. 14 tests to 10, and the
suite drops from 8.5s to 1.6s. With the guard disabled the same five tests
still fail, so no coverage is lost.
Also correct the class javadoc, which listed TOP_DOCS as exhaustive when
isExhaustive() returns false for it, drop the PR number and "verification
harness" framing, reword the comment referring to a ScoreMode.COMPLETE
javadoc sentence removed in the previous commit, and import
NumericDocValuesField and IntPoint instead of qualifying them inline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # lucene/CHANGES.txt
|
Thanks for putting these inline, that made them easy to work through. All eight points taken, and all of them were right. Pushed as Summary of the changes
14 tests to 10, suite 8.5s to 1.6s. To confirm the trimming did not weaken anything: with One thing I should have caught before you saw this. Beasting turned up a flake that this PR introduces into 22 failures in 500 iterations, seed Both now use That does raise a question I would rather you decide than decide myself: Verification
Toolchain: Temurin JDK 25.0.4.1, Gradle 9.7.0. The branch is mergeable again after the Could you take another look when you have a moment? If it looks good to you now I would appreciate an approval and a merge. And if you would still rather see the test file tighter, or want the |
|
@jimczi — both items you flagged as blocking are in 8161b4f. The four-argument constructor is gone; ScoreMode is explicit at all 45 call sites, including ReadAheadMatchAllDocsQuery, which now passes the scoreMode it already held two lines above — the exact next-caller bug you predicted. The flaky deletions test. Your diagnosis was right. Both deletion tests use a plain IndexWriter and assert reader.hasDeletions(). -Ptests.seed=14471C5085979DA7 passes, as does the full :lucene:core:test on that seed. To show the coverage is genuinely back rather than merely green: with the guard disabled, testConstantScoreQueriesWithDeletionsUnderComplete now fails 30/30 at 4095/19999, where before it depended on the doRandomForceMerge() coin flip. Trimming per your note: 14 tests → 10, suite 8.5s → 1.6s. With the guard disabled the same five tests you identified still fail, so nothing was lost. One I should have caught before you saw it: beasting found that this PR introduced a flake into TestMultiCollector. I'd changed newSearcher(reader, false, false, false) to true, true, true; the third argument is wrapWithAssertions, and that test deliberately violates the setMinCompetitiveScore contract, so AssertingScorer fires whenever the random draw produces an AssertingIndexSearcher. Both call sites now use true, false, true — keeping the reader wrapping and concurrency, dropping only the assertion wrapping. Clean at 500 and 400 iterations and under -Ptests.nightly=true; 22 failures before the fix. The AssertingScorer invariant itself is left untouched — relaxing "only TOP_SCORES may call setMinCompetitiveScore" is a contract change that belongs in its own issue, not this one. Nothing outstanding on my side. Please approve and merge when you're happy. Thanks alot. |
Summary
Rationale
This follows the issue discussion: queries check ScoreMode eagerly before skipping, and BatchScoreBulkScorer should do the same.
Validation