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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 9c1171700d6 branch-4.2: [improvement](be) Speed up dense unscored 
inverted index conjunctions (#69172) (#69239)
9c1171700d6 is described below

commit 9c1171700d6399053474da2614ce792a0f19e032
Author: Jack <[email protected]>
AuthorDate: Mon Oct 19 11:36:46 2026 +0800

    branch-4.2: [improvement](be) Speed up dense unscored inverted index 
conjunctions (#69172) (#69239)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #69172, #69206
    
    Problem Summary: Backport #69172 after #69206 introduced the shared
    inverted-index query engine to branch-4.2. Dense unscored conjunctions
    now skip terms held by every row, write a lone term directly to the
    result bitmap, use branch-free block intersection, and bound
    streaming-source batches. Query results are unchanged.
    
    Source PR: https://github.com/apache/doris/pull/69172
    
    Source squash commit: `f937d950d45313f20864d287df453e1b90f5c615`
    (cherry-picked with `-x`).
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test
    - [x] Regression test: `test_match_format_parity` passed on an isolated
    branch-4.2 FE/BE cluster after rebasing onto
    `ecd322ad31193cda19cf4f3b08e0f3f9d12dd67c` (1 suite, 0 failures).
    - [x] Unit Test: `ListedTermsTest.*:CursorChainedPostings.*` passed (41
    tests in 2 suites); all 9 source-added tests were listed and executed.
    No sanitizer report.
    - [x] Manual test: `./build.sh --be -j64` and `./build.sh --fe -j8`
    passed; isolated backend reported `Alive=true`, and `SELECT 1`
    succeeded.
    - Behavior changed:
    - [x] No. Unscored conjunction execution is faster; query results are
    unchanged.
    - Does this need documentation?
        - [x] No.
    
    ### Validation
    
    - `build-support/check-format.sh`: PASS.
    - Complete merged-tree format preflight against `origin/branch-4.2`:
    PASS.
    - Complete PR diff English description check: PASS (5 source files, no
    exceptions).
    - Branch-4.2 has no `build-support/run-clang-tidy.sh`; the branch's BE
    build and format gates passed.
    
    ### Hunk audit
    
    The source squash patch ID matches the final source PR head. The
    cherry-pick was clean, and every changed file has a byte-identical
    postimage to the source commit.
    
    | Source file | Disposition |
    | --- | --- |
    |
    `be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp`
    | Ported (verbatim) |
    | `be/src/storage/index/inverted/query/exec/cursor_chained_postings.cpp`
    | Ported (verbatim) |
    | `be/src/storage/index/inverted/query/exec/cursor_chained_postings.h` |
    Ported (verbatim) |
    |
    
`be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp`
    | Ported (verbatim) |
    |
    `be/test/storage/index/inverted/query/exec/cursor_chained_postings_test.cpp`
    | Ported (verbatim) |
    
    `git range-diff` shows only the `-x` cherry-pick provenance line; the
    code patch is unchanged.
---
 .../query/engine/boolean_query/listed_terms.cpp    |  35 ++++-
 .../query/exec/cursor_chained_postings.cpp         | 169 +++++++++++++++++++--
 .../inverted/query/exec/cursor_chained_postings.h  |  16 ++
 .../engine/boolean_query/listed_terms_test.cpp     |  92 +++++++++--
 .../query/exec/cursor_chained_postings_test.cpp    | 168 ++++++++++++++++++++
 5 files changed, 460 insertions(+), 20 deletions(-)

diff --git 
a/be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp 
b/be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp
index 00a4ff1bd1e..59828cbae42 100644
--- a/be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp
+++ b/be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp
@@ -90,8 +90,41 @@ index_query::TruthSet ListedTerms::conjunction(const 
std::vector<uint32_t>* cand
     if (has_absent_term()) {
         return result;
     }
+    // A term held by every row the source lists cannot narrow the 
conjunction, so the chain
+    // lists only the other terms.
+    const uint32_t doc_count = _source->doc_count();
+    std::vector<index_query::PostingsCursor*> narrowing;
+    for (const auto& cursor : _cursors) {
+        if (cursor->doc_freq() != doc_count) {
+            narrowing.push_back(cursor.get());
+        }
+    }
+    if (narrowing.empty()) {
+        if (candidates == nullptr) {
+            result.true_rows.addRange(0, doc_count);
+        } else {
+            const auto end = std::ranges::lower_bound(*candidates, doc_count);
+            result.true_rows.addMany(static_cast<size_t>(end - 
candidates->begin()),
+                                     candidates->data());
+        }
+        return result;
+    }
+    // A lone term lists its blocks straight into the result instead of 
through a list of its rows.
+    if (narrowing.size() == 1 && candidates == nullptr) {
+        THROW_IF_ERROR(narrowing.front()->prefetch(nullptr, 
/*positions=*/false));
+        index_query::RoaringDocIdSink sink(result.true_rows);
+        THROW_IF_ERROR(index_query::list_blocks(*narrowing.front(), sink));
+        return result;
+    }
+    // A source reading on demand intersects the terms a batch of rows at a 
time, straight into
+    // the result, instead of listing every row of the cheapest term first.
+    if (candidates == nullptr && !_source->batches_reads()) {
+        index_query::RoaringDocIdSink sink(result.true_rows);
+        THROW_IF_ERROR(index_query::stream_cursors(narrowing, sink));
+        return result;
+    }
     std::vector<uint32_t> docs;
-    THROW_IF_ERROR(index_query::chain_cursors(_cursors, candidates, &docs));
+    THROW_IF_ERROR(index_query::chain_cursors(narrowing, candidates, &docs));
     result.true_rows.addMany(docs.size(), docs.data());
     return result;
 }
diff --git 
a/be/src/storage/index/inverted/query/exec/cursor_chained_postings.cpp 
b/be/src/storage/index/inverted/query/exec/cursor_chained_postings.cpp
index bb77e9b29a0..7905a291fde 100644
--- a/be/src/storage/index/inverted/query/exec/cursor_chained_postings.cpp
+++ b/be/src/storage/index/inverted/query/exec/cursor_chained_postings.cpp
@@ -22,6 +22,7 @@
 #include <bit>
 #include <numeric>
 
+#include "common/check.h"
 #include "storage/index/inverted/query/exec/docid_sink.h"
 
 namespace doris::index_query {
@@ -96,12 +97,18 @@ void intersect_through_bit_set(std::span<const uint32_t> 
docs, std::span<const u
             preceding += static_cast<uint32_t>(std::popcount(bits[word]));
         }
     } else {
+        // Every candidate is written and kept only when its bit is set, so no 
branch depends on
+        // whether it matches.
+        std::vector<uint32_t>& rows = *out.rows;
+        const size_t begin = rows.size();
+        rows.resize(begin + candidates.size());
+        uint32_t* next = rows.data() + begin;
         for (const uint32_t candidate : candidates) {
             const uint32_t off = candidate - first;
-            if ((bits[off >> 6] & (uint64_t {1} << (off & 63))) != 0) {
-                out.append(candidate, 0);
-            }
+            *next = candidate;
+            next += (bits[off >> 6] >> (off & 63)) & 1;
         }
+        rows.resize(static_cast<size_t>(next - rows.data()));
     }
 }
 
@@ -192,10 +199,7 @@ Status 
CursorChainedPostings::collect(std::vector<uint32_t>* out) {
     if (_candidates == nullptr) {
         out->reserve(out->size() + _cursor.doc_freq());
         VectorDocIdSink sink(*out);
-        return for_each_block(_cursor, [&sink](const PostingsBlock& block) {
-            return block.dense ? sink.append_range(block.range_begin, 
block.range_end)
-                               : sink.append_sorted(block.docs);
-        });
+        return list_blocks(_cursor, sink);
     }
     out->reserve(out->size() + std::min<uint64_t>(_candidates->size(), 
_cursor.doc_freq()));
     return for_each_candidate_block(
@@ -219,8 +223,11 @@ Status 
CursorChainedPostings::collect(std::vector<uint32_t>* out) {
             });
 }
 
-Status chain_cursors(std::span<const std::unique_ptr<PostingsCursor>> cursors,
-                     const std::vector<uint32_t>* candidates, 
std::vector<uint32_t>* rows) {
+namespace {
+
+template <typename Cursors>
+Status chain(const Cursors& cursors, const std::vector<uint32_t>* candidates,
+             std::vector<uint32_t>* rows) {
     std::vector<CursorChainedPostings> terms;
     terms.reserve(cursors.size());
     std::vector<ChainedPostings*> chain;
@@ -232,4 +239,148 @@ Status chain_cursors(std::span<const 
std::unique_ptr<PostingsCursor>> cursors,
     return chained_conjunction(chain, candidates, rows);
 }
 
+} // namespace
+
+Status chain_cursors(std::span<const std::unique_ptr<PostingsCursor>> cursors,
+                     const std::vector<uint32_t>* candidates, 
std::vector<uint32_t>* rows) {
+    return chain(cursors, candidates, rows);
+}
+
+Status chain_cursors(std::span<PostingsCursor* const> cursors,
+                     const std::vector<uint32_t>* candidates, 
std::vector<uint32_t>* rows) {
+    return chain(cursors, candidates, rows);
+}
+
+namespace {
+
+// The most rows of the cheapest cursor a stream lists at once.
+constexpr size_t kStreamRows = size_t {1} << 16;
+
+// The cheapest cursor of a stream, handing on its rows a batch at a time.
+class StreamLead {
+public:
+    explicit StreamLead(PostingsCursor& cursor) : _cursor(cursor) {}
+
+    Status start() { return _cursor.next_block(&_block, &_eof); }
+    bool exhausted() const { return _eof; }
+
+    // Replaces `rows` by the cursor's next rows, at most kStreamRows of them; 
the rest of the
+    // current block waits for the next batch.
+    Status take_batch(std::vector<uint32_t>* rows) {
+        rows->clear();
+        while (!_eof) {
+            const size_t size = _block.size();
+            const size_t stop = std::min(size, _taken + (kStreamRows - 
rows->size()));
+            if (_block.dense) {
+                const size_t old = rows->size();
+                rows->resize(old + stop - _taken);
+                std::iota(rows->begin() + old, rows->end(),
+                          static_cast<uint32_t>(_block.range_begin + _taken));
+            } else {
+                rows->insert(rows->end(), _block.docs.begin() + _taken, 
_block.docs.begin() + stop);
+            }
+            if (stop < size) {
+                _taken = stop;
+                return Status::OK();
+            }
+            RETURN_IF_ERROR(_cursor.next_block(&_block, &_eof));
+            _taken = 0;
+            if (rows->size() == kStreamRows) {
+                return Status::OK();
+            }
+        }
+        return Status::OK();
+    }
+
+private:
+    PostingsCursor& _cursor;
+    PostingsBlock _block;
+    size_t _taken = 0;
+    bool _eof = false;
+};
+
+// A cursor narrowing a stream's rows; it keeps its current block for the next 
batch's rows.
+class StreamFollower {
+public:
+    explicit StreamFollower(PostingsCursor& cursor) : _cursor(cursor) {}
+
+    // Appends the ascending `rows` the cursor holds to `out`; `ended` once it 
holds no later row.
+    Status narrow(std::span<const uint32_t> rows, std::vector<uint32_t>* out, 
bool* ended) {
+        size_t next = 0;
+        while (next < rows.size()) {
+            if (!_loaded || _last < rows[next]) {
+                bool eof = false;
+                RETURN_IF_ERROR(_cursor.seek_block(rows[next], &_block, &eof));
+                if (eof) {
+                    *ended = true;
+                    return Status::OK();
+                }
+                _loaded = true;
+                _last = _block.doc_at(_block.size() - 1);
+            }
+            const uint32_t first = _block.doc_at(0);
+            const size_t begin =
+                    gallop_past(rows, next, [first](uint32_t doc) { return doc 
< first; });
+            next = gallop_past(rows, begin, [this](uint32_t doc) { return doc 
<= _last; });
+            if (next == begin) {
+                continue;
+            }
+            const auto slice = rows.subspan(begin, next - begin);
+            // A dense block holds every row in its span.
+            if (_block.dense) {
+                out->insert(out->end(), slice.begin(), slice.end());
+            } else {
+                intersect_block(_block.docs, slice, DocumentOutput {out});
+            }
+        }
+        return Status::OK();
+    }
+
+private:
+    PostingsCursor& _cursor;
+    PostingsBlock _block;
+    uint32_t _last = 0;
+    bool _loaded = false;
+};
+
+} // namespace
+
+Status stream_cursors(std::span<PostingsCursor* const> cursors, DocIdSink& 
sink) {
+    DORIS_CHECK(!cursors.empty());
+    std::vector<PostingsCursor*> order(cursors.begin(), cursors.end());
+    std::ranges::stable_sort(order, {},
+                             [](const PostingsCursor* cursor) { return 
cursor->doc_freq(); });
+    for (PostingsCursor* cursor : order) {
+        RETURN_IF_ERROR(cursor->prefetch(nullptr, /*positions=*/false));
+    }
+    StreamLead lead(*order.front());
+    std::vector<StreamFollower> followers;
+    followers.reserve(order.size() - 1);
+    for (size_t i = 1; i < order.size(); ++i) {
+        followers.emplace_back(*order[i]);
+    }
+    std::vector<uint32_t> rows;
+    std::vector<uint32_t> narrowed;
+    RETURN_IF_ERROR(lead.start());
+    while (!lead.exhausted()) {
+        RETURN_IF_ERROR(lead.take_batch(&rows));
+        bool ended = false;
+        for (StreamFollower& follower : followers) {
+            narrowed.clear();
+            RETURN_IF_ERROR(follower.narrow(rows, &narrowed, &ended));
+            rows.swap(narrowed);
+            if (rows.empty()) {
+                break;
+            }
+        }
+        if (!rows.empty()) {
+            RETURN_IF_ERROR(sink.append_sorted(rows));
+        }
+        if (ended) {
+            return Status::OK();
+        }
+    }
+    return Status::OK();
+}
+
 } // namespace doris::index_query
diff --git a/be/src/storage/index/inverted/query/exec/cursor_chained_postings.h 
b/be/src/storage/index/inverted/query/exec/cursor_chained_postings.h
index 1c6e829c037..ae95fd35851 100644
--- a/be/src/storage/index/inverted/query/exec/cursor_chained_postings.h
+++ b/be/src/storage/index/inverted/query/exec/cursor_chained_postings.h
@@ -26,6 +26,7 @@
 
 #include "common/status.h"
 #include "storage/index/inverted/query/exec/chained_conjunction.h"
+#include "storage/index/inverted/query/exec/docid_sink.h"
 #include "storage/index/inverted/query/spi/postings_cursor.h"
 
 namespace doris::index_query {
@@ -60,6 +61,14 @@ Status for_each_block(PostingsCursor& cursor, Visit&& visit) 
{
     }
 }
 
+// Appends every document the cursor lists from its position to `sink`, a 
dense block as a range.
+inline Status list_blocks(PostingsCursor& cursor, DocIdSink& sink) {
+    return for_each_block(cursor, [&sink](const PostingsBlock& block) {
+        return block.dense ? sink.append_range(block.range_begin, 
block.range_end)
+                           : sink.append_sorted(block.docs);
+    });
+}
+
 // Calls `visit(block, slice)` for every block of the cursor that can hold one 
of the
 // ascending `candidates`, with the candidates between its first and last 
document. Only those
 // blocks are decoded.
@@ -123,5 +132,12 @@ private:
 // narrows the cheapest cursor's rows by the others.
 Status chain_cursors(std::span<const std::unique_ptr<PostingsCursor>> cursors,
                      const std::vector<uint32_t>* candidates, 
std::vector<uint32_t>* rows);
+Status chain_cursors(std::span<PostingsCursor* const> cursors,
+                     const std::vector<uint32_t>* candidates, 
std::vector<uint32_t>* rows);
+
+// Appends to `sink` the rows every one of `cursors` holds, a batch of at most 
2^16 rows of the
+// cheapest cursor at a time, which the others narrow, so no list holds more 
than a batch. Each
+// cursor is read in order, which suits a source that reads on demand.
+Status stream_cursors(std::span<PostingsCursor* const> cursors, DocIdSink& 
sink);
 
 } // namespace doris::index_query
diff --git 
a/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
 
b/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
index 04ea00e21a3..fe3ec36a32f 100644
--- 
a/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
+++ 
b/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
@@ -23,6 +23,7 @@
 #include <functional>
 #include <map>
 #include <memory>
+#include <numeric>
 #include <roaring/roaring.hh>
 #include <string>
 #include <utility>
@@ -161,6 +162,13 @@ FakeIndexSource::Posting posting(uint32_t doc, 
std::vector<uint32_t> positions)
     return {.doc = doc, .positions = std::move(positions)};
 }
 
+// Every row of a source holding `doc_count` rows.
+std::vector<uint32_t> every_row(uint32_t doc_count) {
+    std::vector<uint32_t> docs(doc_count);
+    std::iota(docs.begin(), docs.end(), 0U);
+    return docs;
+}
+
 // More terms than a source batching its reads reads in one round, and their 
names in the waves
 // the engine reads them in.
 constexpr size_t kManyTerms = 40;
@@ -476,6 +484,71 @@ TEST_F(ListedTermsTest, 
AConjunctionChainsItsTermsFromTheCheapest) {
     EXPECT_EQ(listed->fetches, 0U);
 }
 
+// A term held by every row cannot narrow a conjunction, so the chain never 
reads it.
+TEST_F(ListedTermsTest, AConjunctionSkipsATermHeldByEveryRow) {
+    for (const bool batches : {false, true}) {
+        auto input = source(batches);
+        input->add("every", every_row(input->doc_count()));
+        ListedTerms group(input, nullptr);
+        group.add(0, "a");
+        group.add(1, "every");
+        group.add(2, "b");
+        group.open(/*conjunctive=*/true);
+        EXPECT_EQ(group.conjunction(nullptr).true_rows, 
roaring::Roaring::bitmapOf(3, 2U, 3U, 8U));
+        EXPECT_TRUE(input->prefetches["every"].empty());
+        EXPECT_EQ(input->prefetches["a"].size(), 1U);
+        EXPECT_EQ(input->prefetches["b"].size(), 1U);
+    }
+}
+
+// A conjunction of terms held by every row holds every row, or every 
candidate, without reading
+// any of them.
+TEST_F(ListedTermsTest, AConjunctionOfTermsHeldByEveryRowHoldsEveryRow) {
+    auto input = source(true);
+    input->add("every", every_row(input->doc_count()));
+    input->add("each", every_row(input->doc_count()));
+    const auto both = [&] {
+        ListedTerms group(input, nullptr);
+        group.add(0, "every");
+        group.add(1, "each");
+        group.open(/*conjunctive=*/true);
+        return group;
+    };
+    roaring::Roaring all_rows;
+    all_rows.addRange(0, input->doc_count());
+    EXPECT_EQ(both().conjunction(nullptr).true_rows, all_rows);
+    const std::vector<uint32_t> candidates {3, 8, 20};
+    EXPECT_EQ(both().conjunction(&candidates).true_rows,
+              roaring::Roaring::bitmapOf(3, 3U, 8U, 20U));
+    EXPECT_TRUE(input->prefetches["every"].empty());
+    EXPECT_TRUE(input->prefetches["each"].empty());
+}
+
+// The one term left beside a term held by every row lists its own rows, or 
those among the
+// candidates.
+TEST_F(ListedTermsTest, ALoneTermListsItsRowsOrThoseAmongTheCandidates) {
+    for (const bool batches : {false, true}) {
+        auto input = source(batches);
+        input->add("every", every_row(input->doc_count()));
+        const auto lone = [&] {
+            ListedTerms group(input, nullptr);
+            group.add(0, "every");
+            group.add(1, "c");
+            group.open(/*conjunctive=*/true);
+            return group;
+        };
+        EXPECT_EQ(lone().conjunction(nullptr).true_rows,
+                  roaring::Roaring::bitmapOf(3, 3U, 8U, 20U));
+        const std::vector<uint32_t> candidates {2, 8, 20, 30};
+        EXPECT_EQ(lone().conjunction(&candidates).true_rows,
+                  roaring::Roaring::bitmapOf(2, 8U, 20U));
+        ASSERT_EQ(input->prefetches["c"].size(), 2U);
+        EXPECT_TRUE(input->prefetches["c"][0].whole);
+        EXPECT_EQ(input->prefetches["c"][1].candidates, candidates);
+        EXPECT_TRUE(input->prefetches["every"].empty());
+    }
+}
+
 // Collecting every row of a scored disjunction lists its terms one after 
another; a TopK on a
 // source that does not batch its reads streams them side by side instead of 
holding every
 // matching row and score first, and ranks the rows alike.
@@ -576,9 +649,10 @@ TEST_F(ListedTermsTest, 
OtherClausesRunInCostOrderAroundTheChain) {
     EXPECT_TRUE(listed->prefetches["c"][0].whole);
 }
 
-// An unscored conjunction chains its terms on a source reading them one at a 
time too: the
-// chain seeks the blocks the cheaper terms' rows fall in, and the source has 
no round to fetch.
-TEST_F(ListedTermsTest, AConjunctionOnAStreamingSourceChainsItsTermsToo) {
+// An unscored conjunction on a source reading its terms one at a time streams 
them a window of
+// rows at a time, so each term may be asked for any of its rows, and the 
source has no round to
+// fetch.
+TEST_F(ListedTermsTest, AConjunctionOnAStreamingSourceStreamsItsTerms) {
     auto streamed = source(false);
     const auto rows = evaluate(
             [&] {
@@ -588,14 +662,12 @@ TEST_F(ListedTermsTest, 
AConjunctionOnAStreamingSourceChainsItsTermsToo) {
     EXPECT_EQ(rows.true_rows, roaring::Roaring::bitmapOf(2, 3U, 8U));
     EXPECT_EQ(rows.null_rows, roaring::Roaring::bitmapOf(2, 40U, 41U));
     EXPECT_EQ(streamed->opened_together, 
(std::vector<std::vector<std::string>> {{"a", "b", "c"}}));
-    ASSERT_EQ(streamed->prefetches["c"].size(), 1U);
-    EXPECT_TRUE(streamed->prefetches["c"][0].whole);
-    ASSERT_EQ(streamed->prefetches["b"].size(), 1U);
-    EXPECT_EQ(streamed->prefetches["b"][0].candidates, (std::vector<uint32_t> 
{3, 8, 20}));
-    ASSERT_EQ(streamed->prefetches["a"].size(), 1U);
-    EXPECT_EQ(streamed->prefetches["a"][0].candidates, (std::vector<uint32_t> 
{3, 8}));
+    for (const char* term : {"a", "b", "c"}) {
+        ASSERT_EQ(streamed->prefetches[term].size(), 1U);
+        EXPECT_TRUE(streamed->prefetches[term][0].whole);
+    }
     EXPECT_EQ(streamed->fetches, 0U);
-    // A disjunction on it still streams.
+    // A disjunction on it opens its terms one at a time.
     auto any = source(false);
     evaluate([&] { return boolean(OperatorType::OP_OR, {term("a"), 
term("c")}); }, any);
     EXPECT_TRUE(any->opened_together.empty());
diff --git 
a/be/test/storage/index/inverted/query/exec/cursor_chained_postings_test.cpp 
b/be/test/storage/index/inverted/query/exec/cursor_chained_postings_test.cpp
index c5487075a5f..40e3bb1ca81 100644
--- a/be/test/storage/index/inverted/query/exec/cursor_chained_postings_test.cpp
+++ b/be/test/storage/index/inverted/query/exec/cursor_chained_postings_test.cpp
@@ -20,8 +20,12 @@
 #include <gtest/gtest.h>
 
 #include <algorithm>
+#include <array>
 #include <cstdint>
+#include <iterator>
 #include <limits>
+#include <numeric>
+#include <random>
 #include <span>
 #include <utility>
 #include <vector>
@@ -238,6 +242,142 @@ TEST(CursorChainedPostings, 
ChainsCursorsThroughTheSharedConjunction) {
     EXPECT_EQ(wide_prefetches[0].candidates, (std::vector<uint32_t> {3, 9, 
12}));
 }
 
+// The rows a sink is handed, batch by batch, a range as its rows.
+class BatchSink final : public DocIdSink {
+public:
+    Status append_sorted(std::span<const uint32_t> docids) override {
+        batches.emplace_back(docids.begin(), docids.end());
+        return Status::OK();
+    }
+    Status append_range(uint32_t first, uint64_t last_exclusive) override {
+        std::vector<uint32_t> rows(last_exclusive - first);
+        std::iota(rows.begin(), rows.end(), first);
+        batches.push_back(std::move(rows));
+        return Status::OK();
+    }
+
+    std::vector<uint32_t> rows() const {
+        std::vector<uint32_t> all;
+        for (const auto& batch : batches) {
+            all.insert(all.end(), batch.begin(), batch.end());
+        }
+        return all;
+    }
+
+    std::vector<std::vector<uint32_t>> batches;
+};
+
+constexpr uint32_t kBatch = 1U << 16;
+
+// A stream hands on at most 2^16 of the cheapest cursor's rows at a time, and 
another cursor's
+// block reaching into the next batch is read once.
+TEST(CursorChainedPostings, AStreamHandsOnABatchOfRowsAtATime) {
+    BlockedCursor rare({BlockedCursor::dense(0, 40000), 
BlockedCursor::dense(40000, 70000)});
+    BlockedCursor wide({BlockedCursor::listed({5, kBatch - 1, kBatch, 69999, 
70001}),
+                        BlockedCursor::dense(80000, 200000)});
+    const std::vector<PostingsCursor*> cursors = {&wide, &rare};
+    BatchSink sink;
+    ASSERT_TRUE(stream_cursors(cursors, sink).ok());
+    EXPECT_EQ(sink.batches,
+              (std::vector<std::vector<uint32_t>> {{5, kBatch - 1}, {kBatch, 
69999}}));
+    EXPECT_EQ(rare.decoded, 2U);
+    EXPECT_EQ(wide.decoded, 1U);
+}
+
+// The other cursors of a stream read only the blocks the cheapest cursor's 
rows reach.
+TEST(CursorChainedPostings, AStreamReadsOnlyTheBlocksTheCheapestRowsReach) {
+    BlockedCursor rare({BlockedCursor::listed({10, 3 * kBatch + 10})});
+    std::vector<BlockedCursor::Block> blocks;
+    for (uint32_t block = 0; block < 64; ++block) {
+        blocks.push_back(BlockedCursor::dense(block * 4096, block * 4096 + 
4000));
+    }
+    BlockedCursor wide(blocks);
+    const std::vector<PostingsCursor*> cursors = {&wide, &rare};
+    BatchSink sink;
+    ASSERT_TRUE(stream_cursors(cursors, sink).ok());
+    EXPECT_EQ(sink.batches, (std::vector<std::vector<uint32_t>> {{10, 3 * 
kBatch + 10}}));
+    EXPECT_EQ(wide.decoded, 2U);
+}
+
+// A stream ends once a cursor holds no further row, without reading the 
cheapest cursor's later
+// blocks.
+TEST(CursorChainedPostings, AStreamEndsWhenACursorEnds) {
+    BlockedCursor rare({BlockedCursor::dense(0, kBatch),
+                        BlockedCursor::dense(100000, 100000 + kBatch),
+                        BlockedCursor::listed({300000}), 
BlockedCursor::listed({400000})});
+    BlockedCursor wide({BlockedCursor::dense(0, 140000)});
+    const std::vector<PostingsCursor*> cursors = {&rare, &wide};
+    BatchSink sink;
+    ASSERT_TRUE(stream_cursors(cursors, sink).ok());
+    ASSERT_EQ(sink.batches.size(), 2U);
+    EXPECT_EQ(sink.batches[0].size(), kBatch);
+    EXPECT_EQ(sink.batches[1].front(), 100000U);
+    EXPECT_EQ(sink.batches[1].back(), 139999U);
+    EXPECT_EQ(sink.batches[1].size(), 40000U);
+    EXPECT_EQ(rare.decoded, 3U);
+}
+
+// Random cursors of dense and listed blocks, some cheapest ones holding 
several batches: the
+// stream lists the rows the chain lists.
+TEST(CursorChainedPostings, AStreamListsTheRowsTheChainLists) {
+    std::mt19937 random(20261017);
+    for (int round = 0; round < 24; ++round) {
+        const size_t count = 2 + round % 3;
+        std::vector<std::vector<BlockedCursor::Block>> layouts(count);
+        std::vector<uint32_t> expected;
+        for (size_t c = 0; c < count; ++c) {
+            const double rate = std::array {0.002, 0.05, 0.5, 0.97}[(round + 
c) % 4];
+            std::bernoulli_distribution holds(rate);
+            std::vector<uint32_t> docs;
+            for (uint32_t row = 0; row < 3 * kBatch + 4096; ++row) {
+                if (holds(random)) {
+                    docs.push_back(row);
+                }
+            }
+            for (size_t begin = 0; begin < docs.size();) {
+                const size_t end = std::min(docs.size(), begin + 1 + random() 
% 600);
+                if (docs[end - 1] - docs[begin] == end - begin - 1) {
+                    layouts[c].push_back(BlockedCursor::dense(docs[begin], 
docs[end - 1] + 1));
+                } else {
+                    layouts[c].push_back(BlockedCursor::listed(
+                            std::vector<uint32_t>(docs.begin() + begin, 
docs.begin() + end)));
+                }
+                begin = end;
+            }
+            if (c == 0) {
+                expected = docs;
+            } else {
+                std::vector<uint32_t> both;
+                std::ranges::set_intersection(expected, docs, 
std::back_inserter(both));
+                expected.swap(both);
+            }
+        }
+        std::vector<BlockedCursor> streamed;
+        std::vector<BlockedCursor> chained;
+        streamed.reserve(count);
+        chained.reserve(count);
+        for (const auto& layout : layouts) {
+            streamed.emplace_back(layout);
+            chained.emplace_back(layout);
+        }
+        std::vector<PostingsCursor*> stream_input;
+        std::vector<PostingsCursor*> chain_input;
+        for (size_t c = 0; c < count; ++c) {
+            stream_input.push_back(&streamed[c]);
+            chain_input.push_back(&chained[c]);
+        }
+        BatchSink sink;
+        ASSERT_TRUE(stream_cursors(stream_input, sink).ok());
+        std::vector<uint32_t> rows;
+        ASSERT_TRUE(chain_cursors(chain_input, nullptr, &rows).ok());
+        EXPECT_EQ(rows, expected) << round;
+        EXPECT_EQ(sink.rows(), expected) << round;
+        for (const auto& batch : sink.batches) {
+            EXPECT_LE(batch.size(), kBatch) << round;
+        }
+    }
+}
+
 // A cursor without its own block positions reads each asked document through 
its positions.
 TEST(CursorChainedPostings, BlockPositionsByDefaultReadEachDocument) {
     FakePostingsCursor cursor({{.doc = 2, .positions = {1, 4}},
@@ -320,6 +460,34 @@ TEST(CursorChainedPostings, 
OrdinalIntersectionCoversSparseWideAndBoundedBlocks)
     }
 }
 
+// Random documents and candidates of a block's span, as unrelated terms of 
several densities meet.
+TEST(CursorChainedPostings, IntersectionHandlesRandomDensities) {
+    std::mt19937 random(20261016);
+    for (const uint32_t span : {256U, 1024U, 16384U}) {
+        for (const double doc_rate : {0.05, 0.5, 0.95}) {
+            for (const double candidate_rate : {0.05, 0.5, 0.95}) {
+                std::bernoulli_distribution holds_doc(doc_rate);
+                std::bernoulli_distribution holds_candidate(candidate_rate);
+                std::vector<uint32_t> docs;
+                std::vector<uint32_t> candidates;
+                for (uint32_t doc = 0; doc < span; ++doc) {
+                    if (holds_doc(random)) {
+                        docs.push_back(doc);
+                    }
+                    if (holds_candidate(random)) {
+                        candidates.push_back(doc);
+                    }
+                }
+                ASSERT_FALSE(docs.empty());
+                std::erase_if(candidates, [&](uint32_t doc) {
+                    return doc < docs.front() || doc > docs.back();
+                });
+                check_ordinals(docs, candidates);
+            }
+        }
+    }
+}
+
 TEST(CursorChainedPostings, RetainsPartialSparseBlockOrdinals) {
     BlockedCursor cursor({BlockedCursor::listed({1, 3, 8}), 
BlockedCursor::dense(10, 13),
                           BlockedCursor::listed({20, 21}), 
BlockedCursor::listed({30, 34, 40})});


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

Reply via email to