This is an automated email from the ASF dual-hosted git repository.

airborne12 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 d30058a8033 [fix](inverted index) Share BM25 statistics across 
parallel scanners of a tablet (#69352)
d30058a8033 is described below

commit d30058a80337e56f0f4930d7a75c0df50aa39ffc
Author: guptashivam04 <[email protected]>
AuthorDate: Thu Oct 22 08:14:49 2026 +0530

    [fix](inverted index) Share BM25 statistics across parallel scanners of a 
tablet (#69352)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69313 (BM25 statistics after compaction), #66979 (deleted
    documents in score TopN)
    
    Problem Summary:
    
    A parallel scan (`ParallelScannerBuilder`) splits one tablet into
    several scanners. Each `OlapScanner` then calls
    `CollectionStatistics::collect()` with only the `rs_splits` of its own
    split, so the BM25 doc count, avgdl and term doc frequencies come from a
    subset of the tablet's rowsets. Rows with identical text get different
    scores depending on which scanner reads them.
    
    The split depends on the tablet's total row count,
    `parallel_scan_max_scanners_count` (defaulting to the core count) and
    `parallel_scan_min_rows_per_scanner`. A row's score and its rank in
    `ORDER BY score()` therefore change when unrelated rows are loaded into
    the tablet, and differ between BEs with different core counts.
    Pagination over a score-ordered search returns repeated and missing
    rows.
    
    Reproduction (one bucket, one replica, no concurrent writes):
    
    ```sql
    CREATE TABLE t (
        id BIGINT,
        content TEXT,
        INDEX idx_content (content) USING INVERTED PROPERTIES("parser" = 
"english")
    ) UNIQUE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1
    PROPERTIES ("replication_num" = "1", "enable_unique_key_merge_on_write" = 
"true",
                "disable_auto_compaction" = "true");
    
    -- 8 loads (8 rowsets), each with a different share of matching rows
    INSERT INTO t SELECT 0 * 2000 + number, IF(number % 2  = 0, 'we should try 
these jobs today', 'call the candidate about salary') FROM numbers("number" = 
"2000");
    INSERT INTO t SELECT 1 * 2000 + number, IF(number % 5  = 0, 'we should try 
these jobs today', 'call the candidate about salary') FROM numbers("number" = 
"2000");
    -- ... batches 2..7 with modulus 8, 11, 14, 17, 20, 23
    
    SELECT /*+ SET_VAR(enable_parallel_scan=true, 
parallel_scan_min_rows_per_scanner=1024, parallel_scan_max_scanners_count=16) */
           score() AS s, count(*)
    FROM (SELECT score() FROM t WHERE SEARCH('content:jobs OR content:try') 
ORDER BY score() DESC LIMIT 100000) x
    GROUP BY s;
    ```
    
    On 4.1.4, the 2,280 matching rows all have the same text but get **15
    distinct scores (1.34 to 5.81)**. With `enable_parallel_scan=false` they
    all get 3.647686. On a production-sized table, the default thresholds
    give the same behavior.
    
    Fix:
    
    - `TabletCollectionStatistics` holds the rowsets of a tablet's entire
    read source. The first scanner of the tablet to prepare collects the
    statistics once with `collect_full_collection()`, and every scanner of
    that tablet copies them into its own `CollectionStatistics`. Later
    scanners wait for the first collection and get its result or error.
    - `CollectionStatistics::copy_collected_from()` copies the collected
    counters and resets the derived idf/avgdl caches, so no cache is shared
    between scanner threads.
    - `ParallelScannerBuilder` creates one holder per tablet, only when the
    scan has a `score()` runtime. Both the by-rowid and per-segment build
    paths pass it to their scanners.
    - The non-parallel path is unchanged. Each of its scanners already
    receives the tablet's full read source.
    
    Statistics remain per tablet. Making them consistent across tablets and
    BEs (as with Elasticsearch's `dfs_query_then_fetch`) is out of scope.
---
 be/src/exec/operator/olap_scan_operator.cpp        |  4 ++
 be/src/exec/scan/olap_scanner.cpp                  | 14 +++-
 be/src/exec/scan/olap_scanner.h                    |  5 ++
 be/src/exec/scan/parallel_scanner_builder.cpp      | 34 ++++++++--
 be/src/exec/scan/parallel_scanner_builder.h        | 18 +++--
 .../inverted/reader/collection_statistics.cpp      | 27 ++++++++
 .../index/inverted/reader/collection_statistics.h  | 32 +++++++++
 be/test/exec/scan/scanner_late_arrival_rf_test.cpp |  2 +-
 .../inverted/reader/collection_statistics_test.cpp | 32 +++++++++
 ...test_bm25_score_parallel_scan_statistics.groovy | 78 ++++++++++++++++++++++
 10 files changed, 233 insertions(+), 13 deletions(-)

diff --git a/be/src/exec/operator/olap_scan_operator.cpp 
b/be/src/exec/operator/olap_scan_operator.cpp
index a287d1d479b..a61fa641819 100644
--- a/be/src/exec/operator/olap_scan_operator.cpp
+++ b/be/src/exec/operator/olap_scan_operator.cpp
@@ -833,6 +833,8 @@ Status 
OlapScanLocalState::_init_scanners(std::list<ScannerSPtr>* scanners) {
                 std::max<int64_t>(1024, 
state()->parallel_scan_min_rows_per_scanner());
         scanner_builder.set_max_scanners_count(max_scanners_count);
         scanner_builder.set_min_rows_per_scanner(min_rows_per_scanner);
+        // BM25 statistics must not depend on how a tablet is split into 
scanners.
+        scanner_builder.set_share_tablet_collection_statistics(_score_runtime 
!= nullptr);
         // If the session variable is set, force one scanner per segment.
         if (state()->query_options().__isset.optimize_index_scan_parallelism &&
             state()->query_options().optimize_index_scan_parallelism) {
@@ -934,6 +936,8 @@ Status 
OlapScanLocalState::_init_scanners(std::list<ScannerSPtr>* scanners) {
                                   palo_scan_range.__isset.end_tso
                                           ? 
std::make_optional(palo_scan_range.end_tso)
                                           : std::nullopt,
+                                  // Each scanner reads the tablet's entire 
read source.
+                                  nullptr,
                           });
             RETURN_IF_ERROR(scanner->init(state(), _conjuncts));
             scanners->push_back(std::move(scanner));
diff --git a/be/src/exec/scan/olap_scanner.cpp 
b/be/src/exec/scan/olap_scanner.cpp
index d99a641c717..eb0d1604f60 100644
--- a/be/src/exec/scan/olap_scanner.cpp
+++ b/be/src/exec/scan/olap_scanner.cpp
@@ -108,6 +108,7 @@ OlapScanner::OlapScanner(ScanLocalStateBase* parent, 
OlapScanner::Params&& param
           _end_tso(params.end_tso),
           _bucket_seq(params.bucket_seq),
           _bucket_num(params.bucket_num),
+          
_tablet_collection_statistics(std::move(params.tablet_collection_statistics)),
           
_initial_file_cache_stats(std::move(params.initial_file_cache_stats)) {
     _tablet_reader_params.set_read_source(std::move(params.read_source),
                                           _state->skip_delete_bitmap());
@@ -286,9 +287,16 @@ Status OlapScanner::_prepare_impl() {
                 _state, _tablet_reader_params.reader_type, 
tablet->file_cache_ttl_expiration_time(),
                 &_tablet_reader->mutable_stats()->file_cache_stats);
 
-        RETURN_IF_ERROR(_tablet_reader_params.collection_statistics->collect(
-                _state, _tablet_reader_params.rs_splits, 
_tablet_reader_params.tablet_schema,
-                _tablet_reader_params.common_expr_ctxs_push_down, &io_ctx));
+        if (_tablet_collection_statistics != nullptr) {
+            RETURN_IF_ERROR(_tablet_collection_statistics->copy_to(
+                    _state, _tablet_reader_params.tablet_schema,
+                    _tablet_reader_params.common_expr_ctxs_push_down, &io_ctx,
+                    _tablet_reader_params.collection_statistics.get()));
+        } else {
+            
RETURN_IF_ERROR(_tablet_reader_params.collection_statistics->collect(
+                    _state, _tablet_reader_params.rs_splits, 
_tablet_reader_params.tablet_schema,
+                    _tablet_reader_params.common_expr_ctxs_push_down, 
&io_ctx));
+        }
     }
 
     _has_prepared = true;
diff --git a/be/src/exec/scan/olap_scanner.h b/be/src/exec/scan/olap_scanner.h
index 0d46ce31996..4a4f45e0a88 100644
--- a/be/src/exec/scan/olap_scanner.h
+++ b/be/src/exec/scan/olap_scanner.h
@@ -59,6 +59,7 @@ struct IOContext;
 } // namespace io
 
 class Block;
+class TabletCollectionStatistics;
 
 io::IOContext build_score_runtime_collection_io_context(RuntimeState* state, 
ReaderType reader_type,
                                                         int64_t 
expiration_time,
@@ -84,6 +85,9 @@ public:
         int32_t bucket_num = 0;
         std::optional<int64_t> start_tso;
         std::optional<int64_t> end_tso;
+        // Set when the tablet is split across several scanners, so that they 
all score with
+        // statistics of the entire tablet instead of only the rowsets each 
one reads.
+        std::shared_ptr<TabletCollectionStatistics> 
tablet_collection_statistics;
     };
 
     OlapScanner(ScanLocalStateBase* parent, Params&& params);
@@ -127,6 +131,7 @@ private:
     std::optional<int64_t> _end_tso;
     int32_t _bucket_seq;
     int32_t _bucket_num;
+    std::shared_ptr<TabletCollectionStatistics> _tablet_collection_statistics;
 
 public:
     io::FileCacheStatistics _initial_file_cache_stats;
diff --git a/be/src/exec/scan/parallel_scanner_builder.cpp 
b/be/src/exec/scan/parallel_scanner_builder.cpp
index 119caad6c5c..ec624a92749 100644
--- a/be/src/exec/scan/parallel_scanner_builder.cpp
+++ b/be/src/exec/scan/parallel_scanner_builder.cpp
@@ -25,8 +25,10 @@
 #include "common/status.h"
 #include "exec/operator/olap_scan_operator.h"
 #include "exec/scan/olap_scanner.h"
+#include "exprs/function/search/search_predicate_collector.h"
 #include "io/io_common.h"
 #include "runtime/query_context.h"
+#include "storage/index/inverted/reader/collection_statistics.h"
 #include "storage/rowset/beta_rowset.h"
 #include "storage/segment/segment_loader.h"
 #include "storage/tablet/base_tablet.h"
@@ -89,6 +91,8 @@ Status 
ParallelScannerBuilder::_build_scanners_by_rowid(std::list<ScannerSPtr>&
         const auto& scan_range = *_scan_ranges[tablet_idx];
         DCHECK(_all_read_sources.contains(tablet->tablet_id()));
         auto& entire_read_source = _all_read_sources[tablet->tablet_id()];
+        auto tablet_collection_statistics =
+                _create_tablet_collection_statistics(entire_read_source);
 
         if (config::is_cloud_mode()) {
             // FIXME(plat1ko): Avoid pointer cast
@@ -153,7 +157,8 @@ Status 
ParallelScannerBuilder::_build_scanners_by_rowid(std::list<ScannerSPtr>&
                                  .delete_predicates = 
entire_read_source.delete_predicates,
                                  .delete_bitmap = 
entire_read_source.delete_bitmap},
                                 
take_initial_file_cache_stats(&_tablet_preload_file_cache_stats,
-                                                              
tablet->tablet_id())));
+                                                              
tablet->tablet_id()),
+                                tablet_collection_statistics));
 
                         partitial_read_source = {};
                         split = RowSetSplits(reader->clone());
@@ -200,7 +205,8 @@ Status 
ParallelScannerBuilder::_build_scanners_by_rowid(std::list<ScannerSPtr>&
                                     .delete_predicates = 
entire_read_source.delete_predicates,
                                     .delete_bitmap = 
entire_read_source.delete_bitmap},
                                    
take_initial_file_cache_stats(&_tablet_preload_file_cache_stats,
-                                                                 
tablet->tablet_id())));
+                                                                 
tablet->tablet_id()),
+                                   tablet_collection_statistics));
         }
     }
 
@@ -223,6 +229,8 @@ Status 
ParallelScannerBuilder::_build_scanners_by_per_segment(std::list<ScannerS
         const auto& scan_range = *_scan_ranges[tablet_idx];
         DCHECK(_all_read_sources.contains(tablet->tablet_id()));
         auto& entire_read_source = _all_read_sources[tablet->tablet_id()];
+        auto tablet_collection_statistics =
+                _create_tablet_collection_statistics(entire_read_source);
 
         if (config::is_cloud_mode()) {
             // FIXME(plat1ko): Avoid pointer cast
@@ -257,7 +265,8 @@ Status 
ParallelScannerBuilder::_build_scanners_by_per_segment(std::list<ScannerS
                          .delete_predicates = 
entire_read_source.delete_predicates,
                          .delete_bitmap = entire_read_source.delete_bitmap},
                         
take_initial_file_cache_stats(&_tablet_preload_file_cache_stats,
-                                                      tablet->tablet_id())));
+                                                      tablet->tablet_id()),
+                        tablet_collection_statistics));
             }
         }
     }
@@ -307,10 +316,26 @@ Status ParallelScannerBuilder::_load() {
     return Status::OK();
 }
 
+std::shared_ptr<TabletCollectionStatistics>
+ParallelScannerBuilder::_create_tablet_collection_statistics(
+        const TabletReadSource& entire_read_source) const {
+    if (!_share_tablet_collection_statistics) {
+        return nullptr;
+    }
+    std::vector<RowsetSharedPtr> rowsets;
+    rowsets.reserve(entire_read_source.rs_splits.size());
+    for (const auto& rs_split : entire_read_source.rs_splits) {
+        rowsets.emplace_back(rs_split.rs_reader->rowset());
+    }
+    return std::make_shared<TabletCollectionStatistics>(
+            std::move(rowsets), std::make_unique<SearchPredicateCollector>());
+}
+
 std::shared_ptr<OlapScanner> ParallelScannerBuilder::_build_scanner(
         BaseTabletSPtr tablet, int64_t version, const 
std::vector<OlapScanRange*>& key_ranges,
         const TPaloScanRange& scan_range, TabletReadSource&& read_source,
-        io::FileCacheStatistics&& initial_file_cache_stats) {
+        io::FileCacheStatistics&& initial_file_cache_stats,
+        std::shared_ptr<TabletCollectionStatistics> 
tablet_collection_statistics) {
     OlapScanner::Params params {
             .state = _state,
             .profile = _scanner_profile.get(),
@@ -327,6 +352,7 @@ std::shared_ptr<OlapScanner> 
ParallelScannerBuilder::_build_scanner(
             .bucket_num = scan_range.bucket_num,
             .start_tso = std::nullopt,
             .end_tso = std::nullopt,
+            .tablet_collection_statistics = 
std::move(tablet_collection_statistics),
     };
     return OlapScanner::create_shared(_parent, std::move(params));
 }
diff --git a/be/src/exec/scan/parallel_scanner_builder.h 
b/be/src/exec/scan/parallel_scanner_builder.h
index 7a8a704c488..525ae78f39b 100644
--- a/be/src/exec/scan/parallel_scanner_builder.h
+++ b/be/src/exec/scan/parallel_scanner_builder.h
@@ -74,6 +74,9 @@ public:
 
     void set_scan_parallelism_by_per_segment(bool v) { 
_scan_parallelism_by_per_segment = v; }
 
+    // Scanners of one tablet share BM25 statistics collected over the entire 
tablet.
+    void set_share_tablet_collection_statistics(bool v) { 
_share_tablet_collection_statistics = v; }
+
     const OlapReaderStatistics* builder_stats() const { return 
&_builder_stats; }
 
 private:
@@ -84,11 +87,14 @@ private:
     // Build scanners so that each segment is handled by its own scanner.
     Status _build_scanners_by_per_segment(std::list<ScannerSPtr>& scanners);
 
-    std::shared_ptr<OlapScanner> _build_scanner(BaseTabletSPtr tablet, int64_t 
version,
-                                                const 
std::vector<OlapScanRange*>& key_ranges,
-                                                const TPaloScanRange& 
scan_range,
-                                                TabletReadSource&& read_source,
-                                                io::FileCacheStatistics&& 
initial_file_cache_stats);
+    std::shared_ptr<TabletCollectionStatistics> 
_create_tablet_collection_statistics(
+            const TabletReadSource& entire_read_source) const;
+
+    std::shared_ptr<OlapScanner> _build_scanner(
+            BaseTabletSPtr tablet, int64_t version, const 
std::vector<OlapScanRange*>& key_ranges,
+            const TPaloScanRange& scan_range, TabletReadSource&& read_source,
+            io::FileCacheStatistics&& initial_file_cache_stats,
+            std::shared_ptr<TabletCollectionStatistics> 
tablet_collection_statistics);
 
     OlapScanLocalState* _parent;
 
@@ -108,6 +114,8 @@ private:
     // Force building one scanner per segment when true.
     bool _scan_parallelism_by_per_segment {false};
 
+    bool _share_tablet_collection_statistics {false};
+
     std::shared_ptr<RuntimeProfile> _scanner_profile;
     OlapReaderStatistics _builder_stats;
     RuntimeState* _state;
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.cpp 
b/be/src/storage/index/inverted/reader/collection_statistics.cpp
index 07e3fa866d6..1e691caaf15 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.cpp
+++ b/be/src/storage/index/inverted/reader/collection_statistics.cpp
@@ -420,6 +420,33 @@ void CollectionStatistics::clear() {
     _idf_by_col_term.clear();
 }
 
+void CollectionStatistics::copy_collected_from(const CollectionStatistics& 
other) {
+    _total_num_docs = other._total_num_docs;
+    _total_num_tokens = other._total_num_tokens;
+    _term_doc_freqs = other._term_doc_freqs;
+    _avg_dl_by_col.clear();
+    _idf_by_col_term.clear();
+}
+
+Status TabletCollectionStatistics::copy_to(RuntimeState* state,
+                                           const TabletSchemaSPtr& 
tablet_schema,
+                                           const VExprContextSPtrs& 
common_expr_ctxs_push_down,
+                                           io::IOContext* io_ctx,
+                                           CollectionStatistics* statistics) {
+    DORIS_CHECK(statistics != nullptr);
+    std::lock_guard lock(_mutex);
+    if (!_collected) {
+        _status = _statistics.collect_full_collection(state, _rowsets, 
tablet_schema,
+                                                      
common_expr_ctxs_push_down, io_ctx);
+        _collected = true;
+        // Do not hold the rowsets any longer than needed.
+        _rowsets.clear();
+    }
+    RETURN_IF_ERROR(_status);
+    statistics->copy_collected_from(_statistics);
+    return Status::OK();
+}
+
 uint64_t CollectionStatistics::get_term_doc_freq_by_col(const std::wstring& 
lucene_col_name,
                                                         const std::wstring& 
term) {
     const auto field = _term_doc_freqs.find(lucene_col_name);
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.h 
b/be/src/storage/index/inverted/reader/collection_statistics.h
index 27b61b6ad75..0da3daa2dd3 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.h
+++ b/be/src/storage/index/inverted/reader/collection_statistics.h
@@ -70,6 +70,10 @@ public:
                                              const std::wstring& term);
     MOCK_FUNCTION float get_or_calculate_avg_dl(const std::wstring& 
lucene_col_name);
 
+    // Replaces the collected counters with those of `other`. The derived 
idf/avgdl caches are
+    // reset rather than copied, so the copy can be used by another thread 
without sharing them.
+    void copy_collected_from(const CollectionStatistics& other);
+
 private:
     struct SniiScoringSegmentAccumulator {
         uint64_t doc_count = 0;
@@ -112,6 +116,34 @@ private:
 };
 using CollectionStatisticsPtr = std::shared_ptr<CollectionStatistics>;
 
+// BM25 statistics of a whole tablet, shared by every scanner the tablet is 
split into.
+//
+// A parallel scan divides a tablet's rowsets among several scanners. If each 
scanner
+// collected statistics over only its own rowsets, rows with identical content 
would score
+// differently depending on which scanner read them, and the split itself 
depends on the
+// tablet's row count and the number of cores. Collecting once over the entire 
read source
+// gives every row of the tablet the same idf and avgdl.
+class TabletCollectionStatistics {
+public:
+    TabletCollectionStatistics(std::vector<RowsetSharedPtr> rowsets,
+                               PredicateCollectorPtr search_collector)
+            : _rowsets(std::move(rowsets)), 
_statistics(std::move(search_collector)) {}
+
+    // Collects the statistics on the first call, then copies them into 
`statistics`.
+    // Later callers wait for the first collection and receive its result or 
error.
+    Status copy_to(RuntimeState* state, const TabletSchemaSPtr& tablet_schema,
+                   const VExprContextSPtrs& common_expr_ctxs_push_down, 
io::IOContext* io_ctx,
+                   CollectionStatistics* statistics);
+
+private:
+    std::mutex _mutex;
+    bool _collected = false;
+    Status _status;
+    std::vector<RowsetSharedPtr> _rowsets;
+    CollectionStatistics _statistics;
+};
+using TabletCollectionStatisticsPtr = 
std::shared_ptr<TabletCollectionStatistics>;
+
 // Implementation details of the SNII scoring-segment admission math, surfaced 
so
 // collection_statistics_test.cpp can exercise them without compiling the .cpp 
a
 // second time via #include.
diff --git a/be/test/exec/scan/scanner_late_arrival_rf_test.cpp 
b/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
index 5989ce1e7f0..53197920dc7 100644
--- a/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
+++ b/be/test/exec/scan/scanner_late_arrival_rf_test.cpp
@@ -597,7 +597,7 @@ TEST_F(ScannerLateArrivalRfTest, 
parallel_scanner_factory_preserves_bucket_ident
                                    tablet_key_ranges, state, -1, true, true);
 
     auto scanner = builder._build_scanner(tablet, 1, {}, *scan_ranges.front(), 
TabletReadSource {},
-                                          io::FileCacheStatistics {});
+                                          io::FileCacheStatistics {}, nullptr);
     EXPECT_EQ(scanner->_bucket_seq, bucket_seq);
     EXPECT_EQ(scanner->_bucket_num, bucket_num);
     EXPECT_TRUE(scanner->is_pruned_by_runtime_filter());
diff --git 
a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp 
b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
index c5c454bd0b3..02e04d046db 100644
--- a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
+++ b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
@@ -1218,6 +1218,38 @@ protected:
     std::unique_ptr<TestableCollectionStatistics> stats_;
 };
 
+// Scanners of one tablet each copy the tablet's statistics, so they must 
score identically.
+TEST_F(CollectionStatisticsDetailedTest, CopyCollectedFromScoresLikeSource) {
+    stats_->set_total_num_docs(1000);
+    stats_->set_total_num_tokens(L"content", 5000);
+    stats_->set_term_doc_freq(L"content", L"jobs", 37);
+
+    CollectionStatistics copy;
+    copy.copy_collected_from(*stats_);
+
+    EXPECT_FLOAT_EQ(copy.get_or_calculate_idf(L"content", L"jobs"),
+                    stats_->get_or_calculate_idf(L"content", L"jobs"));
+    EXPECT_FLOAT_EQ(copy.get_or_calculate_avg_dl(L"content"),
+                    stats_->get_or_calculate_avg_dl(L"content"));
+
+    // The copy does not share the source's counters or derived caches.
+    stats_->set_term_doc_freq(L"content", L"jobs", 900);
+    CollectionStatistics recopied;
+    recopied.copy_collected_from(*stats_);
+    EXPECT_NE(recopied.get_or_calculate_idf(L"content", L"jobs"),
+              copy.get_or_calculate_idf(L"content", L"jobs"));
+}
+
+TEST(TabletCollectionStatisticsTest, CollectsOnceAndCopiesToEveryCaller) {
+    TabletCollectionStatistics tablet_statistics({}, nullptr);
+    auto tablet_schema = std::make_shared<TabletSchema>();
+
+    CollectionStatistics first;
+    CollectionStatistics second;
+    EXPECT_TRUE(tablet_statistics.copy_to(nullptr, tablet_schema, {}, nullptr, 
&first).ok());
+    EXPECT_TRUE(tablet_statistics.copy_to(nullptr, tablet_schema, {}, nullptr, 
&second).ok());
+}
+
 // SNII scoring requires only positions and norms; statistics come directly 
from the stats block.
 TEST(CollectionStatisticsSniiScoringTest, 
ResolveUsesPhysicalDocAndTokenCounts) {
     auto result = resolve_snii_scoring_segment(3, 7, /*has_positions=*/true, 
/*has_norms=*/true);
diff --git 
a/regression-test/suites/inverted_index_p0/test_bm25_score_parallel_scan_statistics.groovy
 
b/regression-test/suites/inverted_index_p0/test_bm25_score_parallel_scan_statistics.groovy
new file mode 100644
index 00000000000..09a27555c3a
--- /dev/null
+++ 
b/regression-test/suites/inverted_index_p0/test_bm25_score_parallel_scan_statistics.groovy
@@ -0,0 +1,78 @@
+// 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.
+
+// A parallel scan splits one tablet into several scanners. BM25 statistics 
must
+// still cover the whole tablet, so rows with identical text get identical 
scores
+// no matter which scanner reads them.
+suite("test_bm25_score_parallel_scan_statistics", "p0") {
+    def tableName = "test_bm25_score_parallel_scan_statistics"
+
+    sql "DROP TABLE IF EXISTS ${tableName}"
+    sql """
+        CREATE TABLE ${tableName} (
+            id BIGINT,
+            content TEXT,
+            INDEX idx_content (content) USING INVERTED PROPERTIES("parser" = 
"english")
+        ) ENGINE=OLAP
+        UNIQUE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "enable_unique_key_merge_on_write" = "true",
+            "disable_auto_compaction" = "true"
+        );
+    """
+
+    // Each load is one rowset with a different share of matching rows, so 
statistics
+    // collected per scanner would differ between scanners.
+    for (int batch = 0; batch < 8; batch++) {
+        sql """
+            INSERT INTO ${tableName}
+            SELECT ${batch} * 2000 + number,
+                   IF(number % ${batch * 3 + 2} = 0,
+                      'we should try these jobs today',
+                      'call the candidate about salary')
+            FROM numbers("number" = "2000");
+        """
+    }
+    sql "sync"
+
+    def scoreQuery = { String vars ->
+        sql """
+            SELECT /*+ SET_VAR(${vars}) */ score() AS s
+            FROM ${tableName}
+            WHERE SEARCH('content:jobs OR content:try')
+            ORDER BY score() DESC
+            LIMIT 100000
+        """
+    }
+
+    def parallel = scoreQuery("enable_parallel_scan=true, 
parallel_scan_min_rows_per_scanner=1024, parallel_scan_max_scanners_count=16")
+    def serial = scoreQuery("enable_parallel_scan=false")
+
+    def parallelScores = parallel.collect { it[0] }.unique()
+    def serialScores = serial.collect { it[0] }.unique()
+
+    assertTrue(parallel.size() > 0)
+    assertEquals(serial.size(), parallel.size())
+    assertEquals(1, serialScores.size())
+    assertEquals(1, parallelScores.size(),
+            "identical rows got different BM25 scores across scanners: 
${parallelScores}")
+    assertEquals(serialScores[0], parallelScores[0])
+
+    sql "DROP TABLE IF EXISTS ${tableName}"
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to