Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,22 @@ private Plan pushDown(
}

// When limit + offset overflows the long range, the pushed scan limit would wrap to a
// negative value; skip the push-down and let the TopN above the scan apply limit/offset.
// negative value. Fail with the same error as ordinary TopN instead of leaving score()
// unmaterialized and reporting an unrelated score() usage error.
if (Utils.addOverflows(topN.getLimit(), topN.getOffset())) {
return null;
throw new AnalysisException("limit + offset overflows the long range");
}

long scoreLimit = topN.getLimit() + topN.getOffset();
long pushedScoreLimit = shouldDisableSearchTopN(filter.getConjuncts(), extractedScorePredicate)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please check overflow and do not push down like #64633
add a test case for it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before that, the overflow has been checked:
if (Utils.addOverflows(topN.getLimit(), topN.getOffset())) { return null; }
If overflow occurs, an error will occur:“score() function requires WHERE clause with MATCH function, ORDER BY and LIMIT for optimization”,instead of “limit + offset overflows the long range”.
Do I need to change return null; to throw new AnalysisException("limit + offset overflows the long range");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key is: return null (skip optimization) if no wrong result, else throw exception

? 0L : scoreLimit;

// All conditions met, perform the push down.
// This is the core action: push score() as a virtual column and also push the
// topN info.
Plan newScan = scan.appendVirtualColumnsAndTopN(ImmutableList.of(scoreAlias),
ImmutableList.of(), Optional.empty(),
topN.getOrderKeys(), Optional.of(topN.getLimit() + topN.getOffset()),
topN.getOrderKeys(), Optional.of(pushedScoreLimit),
scoreRangeInfo);

// Rebuild the plan tree above the new scan.
Expand Down Expand Up @@ -243,6 +248,19 @@ private Plan pushDown(
return topN.withChildren(newProject);
}

private boolean shouldDisableSearchTopN(Set<Expression> conjuncts, Expression extractedScorePredicate) {
List<Expression> nonScoreConjuncts = conjuncts.stream()
.filter(conjunct -> extractedScorePredicate == null || !conjunct.equals(extractedScorePredicate))
.collect(ImmutableList.toImmutableList());

boolean hasSearchPredicate = nonScoreConjuncts.stream()
.anyMatch(conjunct -> !conjunct.collect(e -> e instanceof SearchExpression).isEmpty());
if (!hasSearchPredicate) {
return false;
}
return nonScoreConjuncts.size() > 1 || !(nonScoreConjuncts.get(0) instanceof SearchExpression);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Account for storage visibility before keeping SEARCH Top-K

A lone SEARCH is not sufficient to make early Top-K safe. For example:

TopN(score DESC, LIMIT 1)
  Project(id, score() AS score)
    Filter(search('title:apple'))
      Scan(unique-key MOW table)

This branch keeps score_sort_limit = 1. In BE, SegmentIterator::_lazy_init() evaluates SEARCH in _get_row_ranges_by_column_conditions() before subtracting _opts.delete_bitmap. If a deleted old version has the highest score and a live row is second, SEARCH returns only the deleted row; the later bitmap subtraction removes it, and the upper TopN cannot recover the live runner-up. SEARCH is supported on MOW tables, so this is reachable even with no extra SQL conjunct. Please either disable the early limit whenever post-SEARCH visibility/range filters may exist, or apply those masks before SEARCH selects Top-K, and add a deleted-highest-score regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the MOW case with EXPLAIN, and the actual plan does not keep a positive score_sort_limit for a SQL-level lone SEARCH on a unique-key MOW table.
For example:

SELECT id, score() AS s
FROM test_search_score_mow_topk_visibility
WHERE search('title:alpha')
ORDER BY s DESC
LIMIT 5;

produces:

SCORE SORT LIMIT: 0
PREDICATES: (search('title:alpha') AND (DORIS_DELETE_SIGN = 0))

So although the SQL text contains only one SEARCH predicate, the scan predicate is not a lone SEARCH in a unique-key table. Doris injects the hidden delete-sign predicate, and this PR classifies it as SEARCH plus an extra predicate, so the early SEARCH Top-K limit is disabled.

I agree that if a unique-key/MOW scan kept a positive score_sort_limit, it would be unsafe because SEARCH Top-K is evaluated before post-SEARCH visibility filters such as delete bitmap / delete-sign filtering. But that is not the actual plan produced by this PR for the MOW case. With SCORE SORT LIMIT: 0, BE takes the full doc-set collection path instead of collect_multi_segment_top_k(), and the upper TopN applies the final limit after visibility filtering.

I also tried to reproduce the deleted-highest-score case on a unique-key MOW table, comparing the SQL-level lone SEARCH query with a control query that forces SCORE SORT LIMIT: 0, and both returned the same correct visible rows. Could you provide a concrete MOW SQL/EXPLAIN where a unique-key MOW lone SEARCH still produces a positive SCORE SORT LIMIT? Otherwise, this P1 seems to be based on a plan shape that this PR does not generate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MOW plan is protected by the injected delete-sign predicate, but that does not close the broader visibility concern. DUP_KEYS provides a concrete counterexample because those tables do not receive __DORIS_DELETE_SIGN__.

For example, insert two SEARCH-matching rows where id 1 scores above id 2, then execute DELETE FROM t WHERE id = 1. The later score query still has the reduced plan

TopN(score DESC, LIMIT 1)
  Project(id, score() AS score)
    Filter(search('title:alpha'))
      Scan(DUP_KEYS table)

so this helper keeps SCORE SORT LIMIT: 1. BE attaches the legacy delete predicate to older rowsets in BetaRowsetReader, but SEARCH consumes IndexQueryContext::query_limit first in function_search.cpp; exact delete_condition_predicates evaluation happens later in SegmentIterator::_evaluate_short_circuit_predicate(). The deleted high scorer therefore consumes Top-1 and is then removed, while the live runner-up was never collected.

Please account for storage delete predicates (and any other late semantic mask) before retaining positive SEARCH Top-K, and add a deleted-highest-score DUP_KEYS regression. I am replying here instead of opening another inline thread because this is concrete evidence for the same invariant already raised above.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is scoped to the case where SEARCH is combined with other planner-visible predicates in the same FE plan, and the Top-K limit may be applied before those remaining predicates are evaluated. The DUP_KEYS + DELETE case is a separate storage-side visibility problem: the delete predicate is applied later than the SEARCH Top-K limit, so the FE rewrite in this PR cannot safely reason about it in the same way.

I’ve filed a separate JIRA to track that broader bug: http://jira.selectdb-in.cc/browse/DORIS-28025.

}

/**
* Extract score range info from a single score predicate.
* Only supports min_score semantics (similar to Elasticsearch):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- This file is automatically generated. You should know what you did if you want to edit this
-- !single_search --
1

-- !search_with_equal_predicate --
3

-- !search_with_plain_equal_predicate --
3

-- !search_with_equal_limit_two --
3
4

-- !search_with_equal_offset --
4

-- !search_with_match_predicate --
3

-- !search_with_range_predicate --
3

-- !multiple_search_predicates --
4

-- !search_with_score_range_only --
1
3

-- !search_with_score_range_and_other_predicate --
3

-- !nested_search_with_other_predicate --
7

-- !not_search_with_other_search --
6
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// 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.

suite("test_search_score_topn_predicates", "p0") {
sql "DROP TABLE IF EXISTS test_search_score_topn_predicates"

sql """
CREATE TABLE test_search_score_topn_predicates (
id INT,
status VARCHAR(20),
plain_status VARCHAR(20),
category VARCHAR(20),
title TEXT,
body TEXT,
INDEX idx_status (status) USING INVERTED,
INDEX idx_category (category) USING INVERTED,
INDEX idx_title (title) USING INVERTED PROPERTIES("parser" = "english", "support_phrase" = "true"),
INDEX idx_body (body) USING INVERTED PROPERTIES("parser" = "english", "support_phrase" = "true")
) ENGINE=OLAP
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_allocation" = "tag.location.default: 1",
"disable_auto_compaction" = "true"
)
"""

sql """
INSERT INTO test_search_score_topn_predicates VALUES
(1, 'drop', 'drop', 'plain', 'apple apple apple apple apple apple apple apple apple apple apple apple', 'alpha'),
(2, 'keep', 'keep', 'plain', 'apple', 'alpha'),
(3, 'keep', 'keep', 'plain', 'apple apple apple apple apple', 'alpha'),
(4, 'keep', 'keep', 'plain', 'apple apple apple', 'beta beta beta'),
(5, 'drop', 'drop', 'plain', 'banana', 'beta beta beta beta beta beta beta beta beta beta beta beta'),
(6, 'keep', 'keep', 'plain', 'pear', 'beta beta beta beta beta'),
(7, 'keep', 'keep', 'special', 'cherry cherry cherry cherry', 'gamma'),
(8, 'drop', 'drop', 'special', 'cherry cherry cherry cherry cherry cherry cherry cherry cherry cherry', 'gamma')
"""

sql "sync"
sql "set enable_nereids_planner = true"
sql "set enable_fallback_to_original_planner = false"
sql "set enable_segment_limit_pushdown = true"
sql "set enable_inverted_index_query_cache = false"

qt_single_search """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Cover the positive branch if it remains

These result-only queries cannot distinguish SCORE SORT LIMIT: 1 from the disable sentinel 0, because the upper TopN produces the same rows either way; the complete changed suite passes if the helper always returns zero. After addressing the visibility issue above, if any selective positive-limit branch remains, please add deterministic EXPLAIN assertions for that demonstrably safe case and for an extra-predicate zero case. If the fix removes positive SEARCH Top-K entirely, this assertion is unnecessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added regression cases are primarily correctness tests. They are constructed so that, for SEARCH plus residual predicates, keeping a positive SCORE SORT LIMIT would prune the high-score row before the residual predicate is applied and produce the wrong result. Therefore the expected results verify the correctness issue that this PR fixes.

SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple')
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_search_with_equal_predicate """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND status = 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_search_with_plain_equal_predicate """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND plain_status = 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_search_with_equal_limit_two """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND status = 'keep'
ORDER BY s DESC
LIMIT 2
) t
ORDER BY id
"""

qt_search_with_equal_offset """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND status = 'keep'
ORDER BY s DESC
LIMIT 1 OFFSET 1
) t
ORDER BY id
"""

qt_search_with_match_predicate """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND status MATCH 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_search_with_range_predicate """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND id > 1
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_multiple_search_predicates """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND search('body:beta')
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_search_with_score_range_only """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND score() > 0
ORDER BY s DESC
LIMIT 2
) t
ORDER BY id
"""

qt_search_with_score_range_and_other_predicate """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple') AND score() > 0 AND status = 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_nested_search_with_other_predicate """

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Make the nested cases depend on recursive SEARCH detection

Neither named case fails with a shallow top-level-only classifier. In the OR case, category MATCH 'special' independently restores id 7 after SEARCH Top-1 keeps id 8; in the NOT case, the separate top-level search('body:beta') already causes the limit to be disabled. If the selective recursive classifier remains after the correctness fix, please make the alternative OR branch nonmatching and add a negated-only witness (or a direct FE rule test) so losing .collect(SearchExpression) changes the expected result.

SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE (search('title:cherry') OR category MATCH 'special') AND status = 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

qt_not_search_with_other_search """
SELECT id FROM (
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE NOT search('title:apple') AND search('body:beta') AND status = 'keep'
ORDER BY s DESC
LIMIT 1
) t
ORDER BY id
"""

// limit + offset overflows the long range. score() must report the standard TopN
// overflow error instead of skipping score pushdown and reporting a score() usage error.
test {
sql """
SELECT id, score() AS s
FROM test_search_score_topn_predicates
WHERE search('title:apple')
ORDER BY s DESC
LIMIT 9223372036854775807 OFFSET 9223372036854775807
"""
exception "limit + offset overflows the long range"
}
}
Loading