This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 40ff995f890 [improvement](inverted index) Short-circuit pushed-down
conjuncts once the row bitmap is empty (#67171)
40ff995f890 is described below
commit 40ff995f890802d5c6877b3e6f390f117d7c5629
Author: Jack <[email protected]>
AuthorDate: Mon Aug 31 09:13:42 2026 +0800
[improvement](inverted index) Short-circuit pushed-down conjuncts once the
row bitmap is empty (#67171)
### What problem does this PR solve?
Problem Summary:
On log-search workloads a query typically carries several pushed-down
conjuncts, e.g. two cheap untokenized MATCH terms plus one expensive
`msg MATCH_PHRASE_PREFIX '...'`:
```sql
SELECT * FROM logs
WHERE _ctime_ >= ... AND _ctime_ <= ...
AND _namespace_ MATCH 'ns' -- untokenized term, ~free
AND _pod_name_ MATCH 'pod-xyz' -- untokenized term, ~free
AND msg MATCH_PHRASE_PREFIX '...' -- whole-segment postings +
positions
ORDER BY _ctime_ DESC LIMIT 1000
```
`SegmentIterator` evaluated ALL pushed-down conjuncts against the index
first and only intersected their bitmaps into `_row_bitmap` afterwards,
so the phrase conjunct paid its full whole-segment postings/positions
cost even when an earlier selective conjunct had already emptied the
candidate bitmap. Reproduced on a production log table: one query
returning 0 rows burned 11,291 CPU seconds and 124 GiB of local index
reads per physical SQL, virtually all of it inside phrase evaluation
(profile: 2,657 segments, InvertedIndexSearcherSearchExecTime = 99.7% of
scan cost, ScanRows = 0).
The empty-bitmap short circuit already exists on the neighbouring paths:
column predicates (`continue_apply` in `_apply_inverted_index`) and
compound AND inside a single expression (`VCompoundPred` COMPOUND_AND
early exit). But top-level AND conjuncts are flattened into separate
expr contexts and take `_apply_index_expr`, which had neither
progressive intersection nor a short circuit, so the most common query
shape never benefited.
Fix: intersect each consumed index result into `_row_bitmap` as soon as
it is produced, and stop evaluating further conjuncts once the bitmap is
empty. A skipped conjunct stays pushed down, so the row-level path keeps
its exact semantics over the now-empty candidate set (zero rows read,
zero cost). Consumed conjuncts are erased from
`_common_expr_ctxs_push_down` only after the ANN range-search pass,
which iterates the same list. A new profile counter
`InvertedIndexConjunctsShortCircuited` reports how many conjuncts were
skipped.
Behavior notes:
- Index results are now intersected before the ANN range-search pass, so
ANN range search executes on the already-narrowed bitmap. AND semantics
are commutative, and a smaller candidate set is what its small-candidate
fallback is designed for.
- Condition cache digest semantics unchanged: a short-circuited (thus
still pushed-down) conjunct keeps the digest cleared, so partial index
results are never cached as full coverage.
### Release note
Inverted index: once earlier pushed-down conjuncts empty the candidate
row bitmap, remaining conjuncts (e.g. expensive MATCH_PHRASE_PREFIX) are
no longer evaluated against the index.
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [x] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [x] Yes. <!-- Index results are intersected progressively (before the
ANN pass); short-circuited conjuncts fall back to the row-level path
over an empty candidate set. Same query results, strictly less index
work. -->
- Does this need documentation?
- [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
---
Review round 2 (automated review, addressed in the third commit):
1. Dead work after the short circuit is now bypassed on an empty
candidate
bitmap: virtual-column MATCH projections skip their whole-segment index
evaluation, the ANN range-search pass breaks out before
loading/searching,
and ANN TopN falls back at entry (all three previously ran even with
zero
surviving rows when the small-candidate fallback thresholds are
disabled).
2. The consumed-list clear no longer drops the proven all-false
condition-cache entry: `_index_conjuncts_proved_empty` keeps the digest
alive for the exhausted case (the empty result is correct for the full
conjunction), while a counter-case test pins the normal fully-consumed
path to its existing digest-zeroing behavior.
---
be/src/exec/operator/olap_scan_operator.cpp | 2 +
be/src/exec/operator/olap_scan_operator.h | 1 +
be/src/exec/scan/olap_scanner.cpp | 2 +
be/src/storage/olap_common.h | 3 +
be/src/storage/segment/segment_iterator.cpp | 63 +++-
be/src/storage/segment/segment_iterator.h | 8 +
...egment_iterator_conjunct_short_circuit_test.cpp | 327 +++++++++++++++++++++
7 files changed, 405 insertions(+), 1 deletion(-)
diff --git a/be/src/exec/operator/olap_scan_operator.cpp
b/be/src/exec/operator/olap_scan_operator.cpp
index b1fb21f764a..d8fcac7579f 100644
--- a/be/src/exec/operator/olap_scan_operator.cpp
+++ b/be/src/exec/operator/olap_scan_operator.cpp
@@ -290,6 +290,8 @@ Status OlapScanLocalState::_init_profile() {
_segment_profile, "InvertedIndexSearcherCacheMiss", TUnit::UNIT,
1);
_inverted_index_downgrade_count_counter =
ADD_COUNTER_WITH_LEVEL(_segment_profile,
"InvertedIndexDowngradeCount", TUnit::UNIT, 1);
+ _inverted_index_conjuncts_short_circuited_counter = ADD_COUNTER_WITH_LEVEL(
+ _segment_profile, "InvertedIndexConjunctsShortCircuited",
TUnit::UNIT, 1);
_inverted_index_analyzer_timer =
ADD_TIMER_WITH_LEVEL(_segment_profile,
"InvertedIndexAnalyzerTime", 1);
_inverted_index_lookup_timer =
diff --git a/be/src/exec/operator/olap_scan_operator.h
b/be/src/exec/operator/olap_scan_operator.h
index 88440fe5bd8..11d40452f93 100644
--- a/be/src/exec/operator/olap_scan_operator.h
+++ b/be/src/exec/operator/olap_scan_operator.h
@@ -265,6 +265,7 @@ private:
RuntimeProfile::Counter* _inverted_index_searcher_cache_hit_counter =
nullptr;
RuntimeProfile::Counter* _inverted_index_searcher_cache_miss_counter =
nullptr;
RuntimeProfile::Counter* _inverted_index_downgrade_count_counter = nullptr;
+ RuntimeProfile::Counter* _inverted_index_conjuncts_short_circuited_counter
= nullptr;
RuntimeProfile::Counter* _inverted_index_analyzer_timer = nullptr;
RuntimeProfile::Counter* _inverted_index_lookup_timer = nullptr;
diff --git a/be/src/exec/scan/olap_scanner.cpp
b/be/src/exec/scan/olap_scanner.cpp
index ed049b8c7ef..4fafd8ffb30 100644
--- a/be/src/exec/scan/olap_scanner.cpp
+++ b/be/src/exec/scan/olap_scanner.cpp
@@ -881,6 +881,8 @@ void OlapScanner::_collect_profile_before_close() {
stats.inverted_index_searcher_cache_miss);
COUNTER_UPDATE(local_state->_inverted_index_downgrade_count_counter,
stats.inverted_index_downgrade_count);
+
COUNTER_UPDATE(local_state->_inverted_index_conjuncts_short_circuited_counter,
+ stats.inverted_index_conjuncts_short_circuited);
COUNTER_UPDATE(local_state->_inverted_index_analyzer_timer,
stats.inverted_index_analyzer_timer);
COUNTER_UPDATE(local_state->_inverted_index_lookup_timer,
stats.inverted_index_lookup_timer);
diff --git a/be/src/storage/olap_common.h b/be/src/storage/olap_common.h
index 55d84154e68..0b05f8fdeb4 100644
--- a/be/src/storage/olap_common.h
+++ b/be/src/storage/olap_common.h
@@ -325,6 +325,9 @@ struct OlapReaderStatistics {
int64_t inverted_index_searcher_cache_hit = 0;
int64_t inverted_index_searcher_cache_miss = 0;
int64_t inverted_index_downgrade_count = 0;
+ // Pushed-down conjuncts skipped (never index-evaluated) because the row
+ // bitmap was already empty when their turn came.
+ int64_t inverted_index_conjuncts_short_circuited = 0;
int64_t inverted_index_analyzer_timer = 0;
int64_t inverted_index_lookup_timer = 0;
// See snii_query_stats.h: one field here instead of one per SNII counter.
diff --git a/be/src/storage/segment/segment_iterator.cpp
b/be/src/storage/segment/segment_iterator.cpp
index 08ec84d700f..1a8ef62134a 100644
--- a/be/src/storage/segment/segment_iterator.cpp
+++ b/be/src/storage/segment/segment_iterator.cpp
@@ -858,7 +858,9 @@ Status
SegmentIterator::_get_row_ranges_by_column_conditions() {
}
}
_opts.condition_cache_digest =
- _common_expr_ctxs_push_down.empty() ? 0 :
_opts.condition_cache_digest;
+ _common_expr_ctxs_push_down.empty() &&
!_index_conjuncts_proved_empty
+ ? 0
+ : _opts.condition_cache_digest;
_opts.stats->rows_inverted_index_filtered += (input_rows -
_row_bitmap.cardinality());
for (uint32_t cid = 0; cid < _schema->num_read_columns(); ++cid) {
bool result_true =
_check_all_conditions_passed_inverted_index_for_column(cid);
@@ -925,6 +927,13 @@ Status SegmentIterator::_apply_ann_topn_predicate() {
if (_ann_topn_runtime == nullptr) {
return Status::OK();
}
+ if (_row_bitmap.isEmpty()) {
+ // Zero candidates: nothing for TopN to select, and the residual
+ // conjuncts that used to veto this path may have been consumed by the
+ // proved-empty short circuit. Fall back before touching the ANN index
+ // (the small-candidate fallback may be disabled by its thresholds).
+ return Status::OK();
+ }
VLOG_DEBUG << fmt::format("Try apply ann topn: {}",
_ann_topn_runtime->debug_string());
// AnnTopNRuntime keeps VSlotRef::column_id(), which is the read-schema
ordinal.
@@ -1237,7 +1246,23 @@ Status SegmentIterator::_apply_index_expr() {
!_opts.runtime_state->query_options().__isset.enable_ann_index_result_cache ||
_opts.runtime_state->query_options().enable_ann_index_result_cache;
+ // Intersect each consumed index result into _row_bitmap right away so a
+ // selective conjunct short-circuits the remaining (potentially expensive,
+ // e.g. MATCH_PHRASE_PREFIX) ones. A skipped conjunct stays pushed down and
+ // keeps its semantics on the row-level path, which then sees zero rows.
+ // Consumed conjuncts are erased only after the ANN pass below, which
+ // iterates the same list.
+ std::vector<const VExprContext*> consumed_by_index;
+ bool bitmap_exhausted = false;
+ size_t considered_conjuncts = 0;
for (const auto& expr_ctx : _common_expr_ctxs_push_down) {
+ if (_row_bitmap.isEmpty()) {
+ _opts.stats->inverted_index_conjuncts_short_circuited +=
+ _common_expr_ctxs_push_down.size() - considered_conjuncts;
+ bitmap_exhausted = true;
+ break;
+ }
+ ++considered_conjuncts;
if (Status st = expr_ctx->evaluate_inverted_index(num_rows());
!st.ok()) {
if (_downgrade_without_index(st) || st.code() ==
ErrorCode::NOT_IMPLEMENTED_ERROR) {
continue;
@@ -1249,12 +1274,25 @@ Status SegmentIterator::_apply_index_expr() {
return st;
}
}
+ if (expr_ctx->all_expr_inverted_index_evaluated()) {
+ const auto* result =
expr_ctx->get_index_context()->get_index_result_for_expr(
+ expr_ctx->root().get());
+ if (result != nullptr) {
+ _row_bitmap &= *result->get_data_bitmap();
+ consumed_by_index.push_back(expr_ctx.get());
+ }
+ }
}
// Evaluate inverted index for virtual column MATCH expressions
(projections).
// Unlike common exprs which filter rows, these only compute index result
bitmaps
// for later materialization via fast_execute().
for (auto& [cid, expr_ctx] : _virtual_column_exprs) {
+ if (_row_bitmap.isEmpty()) {
+ // Zero surviving rows: the projection column is never
materialized,
+ // so its whole-segment index evaluation would be pure waste.
+ break;
+ }
if (expr_ctx->get_index_context() == nullptr) {
continue;
}
@@ -1272,6 +1310,13 @@ Status SegmentIterator::_apply_index_expr() {
// Apply ann range search
for (const auto& expr_ctx : _common_expr_ctxs_push_down) {
+ if (_row_bitmap.isEmpty()) {
+ // A range search intersects into the bitmap; with zero candidates
+ // it cannot add rows, so loading and searching the ANN index
+ // (bypassing the small-candidate fallback when its thresholds are
+ // disabled) would be pure waste.
+ break;
+ }
segment_v2::AnnIndexStats ann_index_stats;
size_t origin_rows = _row_bitmap.cardinality();
bool ann_range_search_executed = false;
@@ -1302,6 +1347,22 @@ Status SegmentIterator::_apply_index_expr() {
_opts.stats->ann_index_range_cache_hits +=
ann_index_stats.range_cache_hits.value();
}
+ if (bitmap_exhausted) {
+ // Zero surviving rows satisfy every remaining conjunct, so the whole
+ // list is consumed -- mirroring the column-predicate short circuit.
+ // This keeps the "all conditions consumed by the index" contract, and
+ // _index_conjuncts_proved_empty keeps the condition-cache digest
+ // alive: the all-false result is correct for the full conjunction, so
+ // clearing the list must not degrade it to "nothing left to cache".
+ _index_conjuncts_proved_empty = true;
+ _common_expr_ctxs_push_down.clear();
+ } else if (!consumed_by_index.empty()) {
+ std::erase_if(_common_expr_ctxs_push_down, [&](const VExprContextSPtr&
ctx) {
+ return std::find(consumed_by_index.begin(),
consumed_by_index.end(), ctx.get()) !=
+ consumed_by_index.end();
+ });
+ }
+
return Status::OK();
}
diff --git a/be/src/storage/segment/segment_iterator.h
b/be/src/storage/segment/segment_iterator.h
index dfe2fd842eb..5602c6280ab 100644
--- a/be/src/storage/segment/segment_iterator.h
+++ b/be/src/storage/segment/segment_iterator.h
@@ -484,6 +484,14 @@ private:
bool _count_fastpath_hit = false;
bool _count_emit_shortcut = false;
uint64_t _count_emit_rows_remaining = 0;
+
+ // An indexed conjunct prefix emptied _row_bitmap, proving the WHOLE
+ // pushed-down conjunction false. Set by the _apply_index_expr short
+ // circuit when it consumes (clears) the remaining conjuncts, and read
+ // where an empty conjunct list would otherwise zero the condition-cache
+ // digest: the all-false result stays valid for the full conjunction, so
+ // it must remain cacheable.
+ bool _index_conjuncts_proved_empty = false;
// Batch size for shortcut emission: VStatisticsIterator's
// MAX_ROW_SIZE_IN_COUNT, the largest default-rows block shape already
// proven through every consumer above the segment iterator by the plain
diff --git
a/be/test/storage/segment/segment_iterator_conjunct_short_circuit_test.cpp
b/be/test/storage/segment/segment_iterator_conjunct_short_circuit_test.cpp
new file mode 100644
index 00000000000..eaf745934b2
--- /dev/null
+++ b/be/test/storage/segment/segment_iterator_conjunct_short_circuit_test.cpp
@@ -0,0 +1,327 @@
+// 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.
+
+// White-box tests for progressive intersection and short-circuit of
pushed-down
+// conjuncts in SegmentIterator::_apply_index_expr. Once an earlier conjunct's
+// index result empties _row_bitmap, the remaining (potentially expensive, e.g.
+// MATCH_PHRASE_PREFIX) conjuncts must not be evaluated against the index at
+// all; skipped conjuncts stay pushed down so the row-level path keeps their
+// semantics over the (now empty) candidate set. Uses the established
+// `#define private public` convention of segment_iterator_limit_opt_test.cpp.
+#include <gtest/gtest.h>
+
+#include <cstdint>
+#include <memory>
+#include <vector>
+
+#include "common/status.h"
+#include "core/data_type/data_type_number.h"
+#include "exprs/vexpr.h"
+#include "exprs/vexpr_context.h"
+#include "runtime/runtime_state.h"
+#include "storage/index/ann/ann_topn_runtime.h"
+#include "storage/index/inverted/inverted_index_reader.h"
+#include "storage/olap_common.h"
+#include "storage/tablet/tablet_schema.h"
+
+#if defined(__clang__)
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wkeyword-macro"
+#endif
+#define private public
+#define protected public
+#include "storage/segment/segment.h"
+#include "storage/segment/segment_iterator.h"
+#undef private
+#undef protected
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
+
+namespace doris::segment_v2 {
+
+namespace {
+
+// A test VExpr that counts evaluations and registers a fixed-cardinality index
+// result bitmap, mimicking how real match exprs publish results.
+class BitmapEvalExpr : public VExpr {
+public:
+ explicit BitmapEvalExpr(std::vector<uint32_t> rows) :
_rows(std::move(rows)) {
+ _data_type = std::make_shared<DataTypeUInt8>();
+ }
+
+ const std::string& expr_name() const override {
+ static const std::string kName = "BitmapEvalExpr";
+ return kName;
+ }
+
+ Status execute(VExprContext*, Block*, int*) const override { return
Status::OK(); }
+
+ Status execute_column_impl(VExprContext* context, const Block* block,
const Selector* selector,
+ size_t count, ColumnPtr& result_column) const
override {
+ return Status::OK();
+ }
+
+ Status evaluate_inverted_index(VExprContext* context, uint32_t
segment_num_rows) override {
+ ++_eval_count;
+ auto data = std::make_shared<roaring::Roaring>();
+ for (uint32_t row : _rows) {
+ data->add(row);
+ }
+ InvertedIndexResultBitmap result(std::move(data),
std::make_shared<roaring::Roaring>());
+ context->get_index_context()->set_index_result_for_expr(this,
std::move(result));
+ return Status::OK();
+ }
+
+ Status evaluate_ann_range_search(
+ const segment_v2::AnnRangeSearchRuntime&,
+ const std::vector<std::unique_ptr<segment_v2::IndexIterator>>&,
+ const std::vector<std::unique_ptr<segment_v2::ColumnIterator>>&,
size_t,
+ roaring::Roaring&, segment_v2::AnnIndexStats&, bool,
+ AnnRangeSearchEvaluationResult& result) override {
+ ++_ann_eval_count;
+ result.executed = false;
+ return Status::OK();
+ }
+
+ int eval_count() const { return _eval_count; }
+ int ann_eval_count() const { return _ann_eval_count; }
+
+private:
+ std::vector<uint32_t> _rows;
+ int _eval_count = 0;
+ int _ann_eval_count = 0;
+};
+
+TabletSchemaSPtr make_tablet_schema() {
+ TabletSchemaPB schema_pb;
+ schema_pb.set_keys_type(KeysType::DUP_KEYS);
+ auto* col = schema_pb.add_column();
+ col->set_unique_id(0);
+ col->set_name("k0");
+ col->set_type("INT");
+ col->set_is_key(true);
+ col->set_is_nullable(false);
+ auto tablet_schema = std::make_shared<TabletSchema>();
+ tablet_schema->init_from_pb(schema_pb);
+ return tablet_schema;
+}
+
+// Minimal Segment stub: _apply_index_expr only consults num_rows().
+std::shared_ptr<Segment> make_stub_segment(uint32_t num_rows,
+ const TabletSchemaSPtr&
tablet_schema) {
+ auto seg = std::make_shared<Segment>(0, RowsetId(), tablet_schema,
InvertedIndexFileInfo());
+ seg->_num_rows = num_rows;
+ return seg;
+}
+
+// VExprContext with a BitmapEvalExpr root and an index context to publish
into.
+VExprContextSPtr make_bitmap_ctx(const std::shared_ptr<BitmapEvalExpr>& expr) {
+ auto ctx = std::make_shared<VExprContext>(expr);
+ std::vector<std::unique_ptr<IndexIterator>> index_iters;
+ std::vector<IndexFieldNameAndTypePair> storage_types;
+ std::unordered_map<ColumnId, std::unordered_map<const VExpr*, bool>>
status_map;
+ ColumnIteratorOptions column_iter_opts;
+ auto index_ctx = std::make_shared<IndexExecContext>(index_iters,
storage_types, status_map,
+ nullptr, nullptr,
column_iter_opts);
+ ctx->set_index_context(index_ctx);
+ return ctx;
+}
+
+} // namespace
+
+class SegmentIteratorConjunctShortCircuitTest : public testing::Test {
+protected:
+ void SetUp() override {
+ _tablet_schema = make_tablet_schema();
+ _segment = make_stub_segment(100, _tablet_schema);
+ _read_schema = std::make_shared<ReadSchema>(_tablet_schema->columns());
+ _iter = std::make_unique<SegmentIterator>(_segment, _read_schema);
+
+ TQueryOptions query_options;
+ query_options.__set_enable_fallback_on_missing_inverted_index(true);
+ _runtime_state.set_query_options(query_options);
+
+ _iter->_opts.runtime_state = &_runtime_state;
+ _iter->_opts.stats = &_stats;
+ }
+
+ std::shared_ptr<Segment> _segment;
+ std::shared_ptr<TabletSchema> _tablet_schema;
+ ReadSchemaSPtr _read_schema;
+ std::unique_ptr<SegmentIterator> _iter;
+ RuntimeState _runtime_state;
+ OlapReaderStatistics _stats;
+};
+
+// Once an earlier conjunct's index result empties the row bitmap, remaining
+// conjuncts must not be evaluated against the index. With zero surviving
+// rows every remaining conjunct is trivially satisfied, so the whole
+// pushed-down list is consumed -- mirroring the column-predicate path and
+// keeping both the "all conditions consumed" contract (debug point
+// segment_iterator.apply_inverted_index) and the condition-cache digest
+// intact.
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
empty_bitmap_short_circuits_remaining) {
+ _iter->_row_bitmap.addRange(0, 100);
+
+ auto empty_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{});
+ auto expensive_expr =
std::make_shared<BitmapEvalExpr>(std::vector<uint32_t> {0, 1, 2, 3, 4});
+ auto empty_ctx = make_bitmap_ctx(empty_expr);
+ auto expensive_ctx = make_bitmap_ctx(expensive_expr);
+ _iter->_common_expr_ctxs_push_down = {empty_ctx, expensive_ctx};
+
+ ASSERT_TRUE(_iter->_apply_index_expr().ok());
+
+ // The empty result is intersected as soon as it is produced ...
+ EXPECT_TRUE(_iter->_row_bitmap.isEmpty());
+ // ... so the expensive conjunct is never evaluated against the index ...
+ EXPECT_EQ(empty_expr->eval_count(), 1);
+ EXPECT_EQ(expensive_expr->eval_count(), 0);
+ // ... and the whole list is consumed: zero surviving rows satisfy every
+ // remaining conjunct, exactly like the column-predicate short circuit.
+ EXPECT_TRUE(_iter->_common_expr_ctxs_push_down.empty());
+ // The skip is visible in reader statistics (profile:
+ // InvertedIndexConjunctsShortCircuited).
+ EXPECT_EQ(_stats.inverted_index_conjuncts_short_circuited, 1);
+}
+
+// Once the candidate bitmap is empty, virtual-column MATCH projections must
+// not run their whole-segment index evaluation either: with zero surviving
+// rows their result column is never materialized, so the postings walk is
+// pure waste (SELECT msg MATCH_PHRASE_PREFIX '...' WHERE ns MATCH 'no-hit').
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
empty_bitmap_skips_virtual_column_projections) {
+ _iter->_row_bitmap.addRange(0, 100);
+
+ auto empty_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{});
+ auto projection_expr =
std::make_shared<BitmapEvalExpr>(std::vector<uint32_t> {1, 2});
+ _iter->_common_expr_ctxs_push_down = {make_bitmap_ctx(empty_expr)};
+ _iter->_virtual_column_exprs[0] = make_bitmap_ctx(projection_expr);
+
+ ASSERT_TRUE(_iter->_apply_index_expr().ok());
+
+ EXPECT_TRUE(_iter->_row_bitmap.isEmpty());
+ EXPECT_EQ(projection_expr->eval_count(), 0);
+}
+
+// The ANN range-search pass iterates the same conjunct list; on an empty
+// bitmap a range search cannot produce rows, so with the small-candidate
+// fallback thresholds disabled it must still not load or search the index.
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
empty_bitmap_skips_ann_range_search) {
+ _iter->_row_bitmap.addRange(0, 100);
+
+ auto empty_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{});
+ auto ann_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t> {1,
2});
+ // The short circuit keeps the second conjunct away from the inverted-index
+ // loop; only the ANN pass below it iterates the (uncleared) list.
+ _iter->_common_expr_ctxs_push_down = {make_bitmap_ctx(empty_expr),
make_bitmap_ctx(ann_expr)};
+
+ ASSERT_TRUE(_iter->_apply_index_expr().ok());
+
+ EXPECT_TRUE(_iter->_row_bitmap.isEmpty());
+ EXPECT_EQ(ann_expr->ann_eval_count(), 0);
+}
+
+// An indexed prefix that empties the bitmap proves the WHOLE conjunction
+// false, which is exactly what the condition cache wants to remember. The
+// consumed-list clear must therefore keep the digest alive (an empty result
+// is correct for the full conjunction) instead of letting the empty list
+// zero it, which would silently drop the all-false cache entry that repeated
+// scans relied on before the short circuit existed.
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
exhausted_prefix_preserves_condition_cache_digest) {
+ _iter->_row_bitmap.addRange(0, 100);
+ _iter->_opts.condition_cache_digest = 12345;
+ TQueryOptions query_options;
+ query_options.__set_enable_fallback_on_missing_inverted_index(true);
+ query_options.__set_enable_inverted_index_query(true);
+ _runtime_state.set_query_options(query_options);
+
+ auto empty_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{});
+ auto residual_expr =
std::make_shared<BitmapEvalExpr>(std::vector<uint32_t> {0, 1});
+ _iter->_common_expr_ctxs_push_down = {make_bitmap_ctx(empty_expr),
+ make_bitmap_ctx(residual_expr)};
+
+ ASSERT_TRUE(_iter->_get_row_ranges_by_column_conditions().ok());
+
+ EXPECT_TRUE(_iter->_row_bitmap.isEmpty());
+ EXPECT_TRUE(_iter->_common_expr_ctxs_push_down.empty());
+ EXPECT_EQ(_iter->_opts.condition_cache_digest, 12345)
+ << "the all-false result is valid for the full conjunction and
must stay cacheable";
+}
+
+// Counter-case guarding against an over-wide digest fix: when every conjunct
+// is consumed normally (no short circuit, surviving rows remain), the digest
+// still zeroes exactly as before -- the preserved digest is only for the
+// proved-empty conjunction.
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
fully_consumed_conjuncts_still_zero_digest) {
+ _iter->_row_bitmap.addRange(0, 100);
+ _iter->_opts.condition_cache_digest = 12345;
+ TQueryOptions query_options;
+ query_options.__set_enable_fallback_on_missing_inverted_index(true);
+ query_options.__set_enable_inverted_index_query(true);
+ _runtime_state.set_query_options(query_options);
+
+ auto first_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{1, 2, 3});
+ auto second_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{2, 3, 4});
+ _iter->_common_expr_ctxs_push_down = {make_bitmap_ctx(first_expr),
+ make_bitmap_ctx(second_expr)};
+
+ ASSERT_TRUE(_iter->_get_row_ranges_by_column_conditions().ok());
+
+ EXPECT_EQ(_iter->_row_bitmap.cardinality(), 2);
+ EXPECT_TRUE(_iter->_common_expr_ctxs_push_down.empty());
+ EXPECT_EQ(_iter->_opts.condition_cache_digest, 0);
+}
+
+// ANN TopN falls back before touching the index once the candidate bitmap is
+// empty: there is nothing to select, and the veto that residual conjuncts
+// used to provide is gone after the consumed-list clear. The fixture leaves
+// _index_iterators empty, so reaching past the guard would index out of
+// bounds -- passing proves the guard runs before any ANN state is touched.
+TEST_F(SegmentIteratorConjunctShortCircuitTest, empty_bitmap_skips_ann_topn) {
+ auto order_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{});
+ _iter->_ann_topn_runtime =
+ std::make_shared<AnnTopNRuntime>(true, 10,
make_bitmap_ctx(order_expr));
+
+ ASSERT_TRUE(_iter->_apply_ann_topn_predicate().ok());
+}
+
+// A conjunct whose index result does not empty the bitmap must not stop
+// evaluation of the following conjuncts; consumed results are intersected
+// progressively.
+TEST_F(SegmentIteratorConjunctShortCircuitTest,
progressive_intersection_keeps_going) {
+ _iter->_row_bitmap.addRange(0, 100);
+
+ auto first_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{1, 2, 3, 50, 60});
+ auto second_expr = std::make_shared<BitmapEvalExpr>(std::vector<uint32_t>
{2, 3, 4});
+ auto first_ctx = make_bitmap_ctx(first_expr);
+ auto second_ctx = make_bitmap_ctx(second_expr);
+ _iter->_common_expr_ctxs_push_down = {first_ctx, second_ctx};
+
+ ASSERT_TRUE(_iter->_apply_index_expr().ok());
+
+ EXPECT_EQ(first_expr->eval_count(), 1);
+ EXPECT_EQ(second_expr->eval_count(), 1);
+ // {1,2,3,50,60} intersected with {2,3,4} is {2,3}.
+ EXPECT_EQ(_iter->_row_bitmap.cardinality(), 2);
+ EXPECT_TRUE(_iter->_row_bitmap.contains(2));
+ EXPECT_TRUE(_iter->_row_bitmap.contains(3));
+ // Both conjuncts were fully consumed by the index; nothing was skipped.
+ EXPECT_TRUE(_iter->_common_expr_ctxs_push_down.empty());
+ EXPECT_EQ(_stats.inverted_index_conjuncts_short_circuited, 0);
+}
+
+} // namespace doris::segment_v2
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]