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 b04fda4c614 branch-4.2: [fix](inverted index) Stream compact docs-only 
SNII postings (#69192) (#69244)
b04fda4c614 is described below

commit b04fda4c614d4c4d9217d7d90c0be2be95c37228
Author: Jack <[email protected]>
AuthorDate: Tue Oct 20 09:25:56 2026 +0800

    branch-4.2: [fix](inverted index) Stream compact docs-only SNII postings 
(#69192) (#69244)
    
    ### What problem does this PR solve?
    
    Issue Number: CIR-22263
    
    Related PRs: #69192 (source), #69206 (branch-4.2 prerequisite)
    
    Problem Summary: Compatible docs-only (T1) SNII postings currently fall
    back to rebuilding from raw columns when the destination does not
    request phrase positions. Backport the source PR's streamed merge
    support while retaining raw rebuild for incompatible or gram sources.
    
    Source squash commit: `8db05407dcf574c90da47579acb0ad7af57e027a`. The
    backport keeps its cherry-pick provenance and adds one branch-4.2
    adaptation commit. The seven SNII implementation and focused test files
    have byte-identical postimages to the source commit.
    
    #### Hunk audit
    
    | Source file or hunk | Disposition |
    | --- | --- |
    | `be/src/storage/index/inverted/snii/compaction/eligibility.cpp` |
    Ported verbatim. |
    | `be/src/storage/index/inverted/snii/compaction/eligibility.h` | Ported
    verbatim. |
    | `be/src/storage/index/inverted/snii/compaction/posting_cursor.cpp` |
    Ported verbatim. |
    |
    `be/src/storage/index/inverted/snii/compaction/snii_index_compaction.cpp`
    | Ported verbatim. |
    |
    `be/src/storage/index/inverted/snii/compaction/snii_index_compaction.h`
    | Ported verbatim. |
    |
    
`be/test/storage/index/inverted/snii/compaction/snii_compaction_eligibility_test.cpp`
    | Ported verbatim. |
    |
    
`be/test/storage/index/inverted/snii/compaction/snii_index_compaction_test.cpp`
    | Ported verbatim. |
    | `be/src/storage/compaction/compaction.cpp` `@@ -1338 +1338,2 @@` |
    N/A: branch-4.2 has no `dest_segment_ids` mapping or `Rowset::segment()`
    API. Its compaction output and adjacent CLucene writer path use
    destination ordinals; nonzero start IDs are assigned only to transient
    partial-update writers. Kept the target's ordinal lookup. |
    |
    
`be/test/storage/index/inverted/clucene/compaction/index_compaction_test.cpp`
    `@@ -1816,7 +1816 @@` | Ported: both sibling indexes must merge. |
    | Same file `@@ -1825,2 +1819,2 @@` | Adapted to the target's local
    segment numbering. |
    | Same file `@@ -1829 +1823 @@` | Ported: both index IDs must be
    eligible. |
    | Same file `@@ -1833,2 +1827,3 @@` | Adapted because the target helper
    has no physical segment start ID parameter. |
    | Same file `@@ -1840,2 +1835,4 @@` | Adapted to
    `Rowset::segment_path()` because `Rowset::segment()` is unavailable. |
    | Same file `@@ -1848 +1844,0 @@` | Ported: removed the obsolete
    raw-rebuild assertion comment. |
    | Same file `@@ -1850,2 +1846,2 @@` | Ported: checks document counts in
    each output segment. |
    | Same file `@@ -1853,0 +1850,10 @@` | Adapted: compares streamed and
    raw-rebuilt bytes by local segment ordinal. |
    
    `git range-diff` matches the source squash to the `-x` cherry-pick and
    shows one additional adaptation commit. The physical-ID lookup is
    omitted for the API and output segment model above; the docs-only
    eligibility and merger changes are preserved.
    
    ### Release note
    
    Compatible docs-only SNII indexes use streamed compaction on branch-4.2.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test:
    
`SniiCompactionEligibilityTest.*:SniiIndexCompactionTest.*:IndexCompactionTest.snii_native_merge*`
    passed, 38/38 tests. The source-added docs-only cases and the sibling
    merge case ran. No ASAN or LeakSanitizer report appeared.
    - [x] Regression test: `test_storage_format_snii` passed, 1 suite, 0
    failures, on an isolated local FE/BE cluster.
    - [x] Manual test: full FE and BE builds passed. The isolated cluster
    reported BE Alive=true and `SELECT 1` succeeded.
    - Behavior changed:
    - [x] Yes. Compatible docs-only postings are streamed rather than
    rebuilt from raw columns.
    - Does this need documentation?
        - [x] No.
    
    The complete committed PR diff passes the English description check. The
    merged target tree passes clang-format 16. The worktree is clean.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../index/inverted/snii/compaction/eligibility.cpp | 61 ++++++++++++-----
 .../index/inverted/snii/compaction/eligibility.h   | 16 ++---
 .../inverted/snii/compaction/posting_cursor.cpp    |  8 +--
 .../snii/compaction/snii_index_compaction.cpp      |  7 +-
 .../snii/compaction/snii_index_compaction.h        |  4 +-
 .../clucene/compaction/index_compaction_test.cpp   | 34 ++++-----
 .../snii_compaction_eligibility_test.cpp           | 80 +++++++++++++++++++++-
 .../snii/compaction/snii_index_compaction_test.cpp | 52 ++++++++++++++
 8 files changed, 207 insertions(+), 55 deletions(-)

diff --git a/be/src/storage/index/inverted/snii/compaction/eligibility.cpp 
b/be/src/storage/index/inverted/snii/compaction/eligibility.cpp
index bff80048d61..928f5ed47cf 100644
--- a/be/src/storage/index/inverted/snii/compaction/eligibility.cpp
+++ b/be/src/storage/index/inverted/snii/compaction/eligibility.cpp
@@ -74,6 +74,26 @@ Status validate_source_shape(const 
reader::LogicalIndexReader& source, size_t so
     return Status::OK();
 }
 
+Status validate_source_shape(const reader::LogicalIndexReader& source, size_t 
source_ordinal,
+                             bool has_positions) {
+    if (source.has_positions() != has_positions ||
+        source.tier() != (has_positions ? format::IndexTier::kT2 : 
format::IndexTier::kT1)) {
+        return reject(
+                fmt::format("source {} position shape differs from 
destination", source_ordinal));
+    }
+    // Gram indexes may omit common postings and need their scheme retained on 
output.
+    if (!has_positions && source.gram_scheme().has_value()) {
+        return reject(fmt::format("source {} carries a gram scheme", 
source_ordinal));
+    }
+    const auto& stats = source.stats();
+    if (stats.indexed_doc_count > stats.doc_count ||
+        stats.null_count != stats.doc_count - stats.indexed_doc_count) {
+        return reject(fmt::format("source {} document statistics violate 
indexed + null = doc",
+                                  source_ordinal));
+    }
+    return Status::OK();
+}
+
 Status reject_legacy_bigram(const reader::LogicalIndexReader& source, size_t 
source_ordinal) {
     bool found_legacy_bigram = false;
     RETURN_IF_ERROR(source.visit_prefix_terms(
@@ -117,8 +137,9 @@ Status resolve_destination_analyzer(const TabletIndex& 
destination_index,
 Status validate_destination_policy(const TabletIndex& destination_index,
                                    const AnalyzerProviderFactory& 
analyzer_provider_factory) {
     const auto& properties = destination_index.properties();
-    if (get_parser_phrase_support_string_from_properties(properties) !=
-        INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES) {
+    if (properties.contains(INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY) &&
+        get_parser_phrase_support_string_from_properties(properties) !=
+                INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES) {
         return reject("destination does not request phrase positions");
     }
     if (!inverted_index::InvertedIndexAnalyzer::should_analyzer(properties)) {
@@ -146,8 +167,10 @@ Status validate_plain_t2_source_eligibility(const 
reader::LogicalIndexReader& so
 
 Status validate_snii_source_eligibility(const reader::LogicalIndexReader& 
source,
                                         size_t source_ordinal,
-                                        const SniiCompactionEligibility& 
/*eligibility*/) {
-    return validate_plain_t2_source_eligibility(source, source_ordinal);
+                                        const SniiCompactionEligibility& 
eligibility) {
+    RETURN_IF_ERROR(
+            validate_source_shape(source, source_ordinal, 
eligibility.destination_has_positions));
+    return reject_legacy_bigram(source, source_ordinal);
 }
 
 Status validate_plain_t2_compaction_eligibility(
@@ -206,10 +229,14 @@ Status validate_snii_compaction_eligibility(
     if (!destination_index.is_inverted_index()) {
         return reject("destination is not an inverted index");
     }
-    if 
(get_parser_phrase_support_string_from_properties(destination_index.properties())
 !=
-        INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES) {
-        return reject("destination does not request phrase positions");
-    }
+    const auto& destination_properties = destination_index.properties();
+    const bool explicit_phrase_policy =
+            
destination_properties.contains(INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY);
+    out->destination_has_positions =
+            explicit_phrase_policy
+                    ? 
get_parser_phrase_support_string_from_properties(destination_properties) ==
+                              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES
+                    : sources.front().reader.get().has_positions();
 
     const TabletIndex& first_index_meta = sources.front().index_meta.get();
     if (!first_index_meta.is_inverted_index()) {
@@ -238,18 +265,18 @@ Status validate_snii_compaction_eligibility(
     }
 
     for (size_t source_ordinal = 0; source_ordinal < sources.size(); 
++source_ordinal) {
-        if (sources[source_ordinal].reader.get().tier() != 
format::IndexTier::kT2) {
-            return reject(fmt::format("source {} is not a positional (T2) 
index", source_ordinal));
-        }
-        
RETURN_IF_ERROR(validate_plain_t2_source_eligibility(sources[source_ordinal].reader.get(),
-                                                             source_ordinal));
+        
RETURN_IF_ERROR(validate_snii_source_eligibility(sources[source_ordinal].reader.get(),
+                                                         source_ordinal, 
*out));
+    }
+    if (out->destination_has_positions) {
+        RETURN_IF_ERROR(validate_destination_policy(destination_index, 
analyzer_provider_factory));
     }
-    RETURN_IF_ERROR(validate_destination_policy(destination_index, 
analyzer_provider_factory));
     // Merged norms are rebuilt from the postings, so the destination follows 
the same norms
     // policy as a fresh write whether or not the sources carry norms.
-    out->destination_writes_norms = 
inverted_index::InvertedIndexAnalyzer::should_analyzer(
-                                            destination_index.properties()) &&
-                                    
should_write_index_norms(destination_index);
+    out->destination_writes_norms =
+            out->destination_has_positions &&
+            
inverted_index::InvertedIndexAnalyzer::should_analyzer(destination_properties) 
&&
+            should_write_index_norms(destination_index);
     return Status::OK();
 }
 
diff --git a/be/src/storage/index/inverted/snii/compaction/eligibility.h 
b/be/src/storage/index/inverted/snii/compaction/eligibility.h
index 8fd5eec28f4..ae6da0090a4 100644
--- a/be/src/storage/index/inverted/snii/compaction/eligibility.h
+++ b/be/src/storage/index/inverted/snii/compaction/eligibility.h
@@ -37,9 +37,7 @@ class LogicalIndexReader;
 
 namespace snii::compaction {
 
-// One already-opened SNII source logical index and the TabletIndex metadata
-// that produced it. References make null inputs unrepresentable; both objects
-// must outlive validate_plain_t2_compaction_eligibility().
+// One opened SNII source and its index metadata. Both objects must outlive 
validation.
 struct PlainT2CompactionSource {
     std::reference_wrapper<const reader::LogicalIndexReader> reader;
     std::reference_wrapper<const TabletIndex> index_meta;
@@ -52,6 +50,8 @@ using AnalyzerProviderFactory = 
std::function<segment_v2::inverted_index::Analyz
         const InvertedIndexAnalyzerConfig&)>;
 
 struct SniiCompactionEligibility {
+    // When the phrase property is absent, preserve the source posting shape.
+    bool destination_has_positions = true;
     // A2: An analyzed destination index with positions must include norms. 
The posting merge
     // rebuilds them from source term frequencies (sum per document, clamped 
to 1..255), so older
     // T2 segments without norms can gain norms without reanalysis.
@@ -69,9 +69,7 @@ Status validate_plain_t2_source(const 
reader::LogicalIndexReader& source, size_t
 Status validate_plain_t2_source_eligibility(const reader::LogicalIndexReader& 
source,
                                             size_t source_ordinal);
 
-// Revalidates one opened source against an already selected streamed shape.
-// CommonGrams checks include exact current versions and static identity 
equality
-// with the eligibility seed.
+// Revalidates one source against the selected destination posting shape.
 Status validate_snii_source_eligibility(const reader::LogicalIndexReader& 
source,
                                         size_t source_ordinal,
                                         const SniiCompactionEligibility& 
eligibility);
@@ -91,10 +89,8 @@ Status validate_plain_t2_compaction_eligibility(
         std::span<const PlainT2CompactionSource> sources, const TabletIndex& 
destination_index,
         const AnalyzerProviderFactory& analyzer_provider_factory = {});
 
-// Accepts either the existing plain positions-only T2 shape or homogeneous,
-// complete CommonGrams T3 sources. Mixed shapes and any CommonGrams identity 
or
-// destination-build-policy mismatch return INVERTED_INDEX_NOT_SUPPORTED so the
-// caller can rebuild from raw columns before creating streamed output.
+// Accepts homogeneous T1 or T2 sources when their posting shape matches the 
destination.
+// Mixed shapes fall back to rebuilding from raw columns.
 Status validate_snii_compaction_eligibility(
         std::span<const PlainT2CompactionSource> sources, const TabletIndex& 
destination_index,
         SniiCompactionEligibility* out,
diff --git a/be/src/storage/index/inverted/snii/compaction/posting_cursor.cpp 
b/be/src/storage/index/inverted/snii/compaction/posting_cursor.cpp
index b2999afc036..2ef64724503 100644
--- a/be/src/storage/index/inverted/snii/compaction/posting_cursor.cpp
+++ b/be/src/storage/index/inverted/snii/compaction/posting_cursor.cpp
@@ -579,12 +579,8 @@ Status SniiPostingCursor::init() {
     if (!read_context_->failed_status().ok()) {
         return read_context_->failed_status();
     }
-    if (index_->tier() == format::IndexTier::kT1) {
-        return Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, false>(
-                "posting_cursor: positions index is required");
-    }
-    if (!index_->has_positions()) {
-        return posting_corruption("positions tier lacks positions capability", 
source_ordinal_);
+    if (index_->has_positions() != term_has_positions_) {
+        return posting_corruption("term position shape differs from source 
index", source_ordinal_);
     }
     if (source_ordinal_ >= rowid_conversion_->source_segment_count()) {
         return Status::Error<ErrorCode::INVALID_ARGUMENT, false>(
diff --git 
a/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.cpp 
b/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.cpp
index a46223caf67..9d74153521f 100644
--- a/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.cpp
+++ b/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.cpp
@@ -315,7 +315,8 @@ writer::TrackedEncodedNorms 
SniiPlainT2MergePlan::take_destination_encoded_norms
 }
 
 format::IndexConfig SniiPlainT2MergePlan::destination_index_config() const {
-    return format::IndexConfig::kDocsPositions;
+    return eligibility_.destination_has_positions ? 
format::IndexConfig::kDocsPositions
+                                                  : 
format::IndexConfig::kDocsOnly;
 }
 
 Status SniiPlainT2MergePlan::poison(Status status) {
@@ -338,8 +339,8 @@ Status 
SniiPlainT2MergePlan::take_front_source(TermMergeFrontier* frontier, Curr
     }
 
     const bool source_has_positions = posting_entry_has_positions(entry);
-    if (!source_has_positions) {
-        return index_compaction_merge_corruption("ordinary term has docs-only 
posting shape");
+    if (source_has_positions != eligibility_.destination_has_positions) {
+        return index_compaction_merge_corruption("term position shape differs 
from destination");
     }
     if (!current->has_positions.has_value()) {
         current->has_positions = source_has_positions;
diff --git 
a/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.h 
b/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.h
index b0902340ee7..266c239e301 100644
--- a/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.h
+++ b/be/src/storage/index/inverted/snii/compaction/snii_index_compaction.h
@@ -37,8 +37,8 @@ namespace doris::snii::compaction {
 
 class TermMergeFrontier;
 
-// Prepared, one-shot merge of one plain positions-only logical index across
-// source segments. prepare() performs every O(1)-metadata, row-id and NULL
+// Prepared, one-shot merge of one homogeneous SNII index across source 
segments.
+// prepare() performs metadata, row-id and NULL
 // preflight before the caller creates destination streamed sessions. execute()
 // then performs exactly one dictionary/posting pass and seals every session.
 //
diff --git 
a/be/test/storage/index/inverted/clucene/compaction/index_compaction_test.cpp 
b/be/test/storage/index/inverted/clucene/compaction/index_compaction_test.cpp
index ea8ef7b46dc..58847b8ba23 100644
--- 
a/be/test/storage/index/inverted/clucene/compaction/index_compaction_test.cpp
+++ 
b/be/test/storage/index/inverted/clucene/compaction/index_compaction_test.cpp
@@ -1805,44 +1805,46 @@ TEST_F(IndexCompactionTest, 
snii_native_merge_validates_rowids_once_and_matches_
     }
 }
 
-// The design's core split: two logical indexes share one column, the eligible
-// one merges natively (no analyzer), the ineligible one (no phrase positions)
-// raw-builds from the column -- in the SAME compaction pass. The old behavior
-// AND-folded eligibility per column and fell back to raw for both.
-TEST_F(IndexCompactionTest, 
snii_native_merge_compacts_eligible_index_and_raw_builds_sibling) {
-    DEFER({});
-
+TEST_F(IndexCompactionTest, 
snii_native_merge_compacts_phrase_and_docs_only_siblings) {
     _build_snii_multi_index_tablet(/*second_supports_phrase=*/false);
     const std::vector<RowsetSharedPtr> rowsets = _build_snii_source_rowsets();
-    auto check_split = [](const BaseCompaction& compaction, const 
RowsetWriterContext& ctx) {
-        // Only the phrase-capable index merges; its no-phrase sibling 
raw-builds.
+    auto check_merge = [](const BaseCompaction& compaction, const 
RowsetWriterContext& ctx) {
         EXPECT_TRUE(ctx.columns_to_do_index_compaction.empty());
         EXPECT_EQ(ctx.snii_indexes_to_do_compaction,
-                  (std::set<std::pair<int32_t, int64_t>> {{1, 11001}}));
+                  (std::set<std::pair<int32_t, int64_t>> {{1, 11001}, {1, 
11002}}));
         EXPECT_EQ(compaction._output_rowset->num_segments(), 2);
     };
     RowsetSharedPtr output;
     const Status status = IndexCompactionUtils::do_compaction(rowsets, 
_engine_ref, _tablet, true,
-                                                              output, 
check_split, 1000);
+                                                              output, 
check_merge, 1000);
     ASSERT_TRUE(status.ok()) << status;
     ASSERT_NE(output, nullptr);
     std::vector<uint32_t> segment_rows;
     output->rowset_meta()->get_num_segment_rows(&segment_rows);
     ASSERT_EQ(segment_rows.size(), output->num_segments());
-    for (uint32_t segment_id = 0; segment_id < output->num_segments(); 
++segment_id) {
-        const auto segment_path = output->segment_path(segment_id);
+    for (uint32_t segment_pos = 0; segment_pos < output->num_segments(); 
++segment_pos) {
+        const auto segment_path = output->segment_path(segment_pos);
         ASSERT_TRUE(segment_path.has_value()) << segment_path.error();
         auto file_reader = IndexCompactionUtils::init_index_file_reader(
                 output, segment_path.value(), 
InvertedIndexStorageFormatPB::SNII);
         for (const TabletIndex* index : _tablet_schema->inverted_indexes()) {
             const auto logical_index = file_reader->open_snii_index(index);
             EXPECT_TRUE(logical_index.has_value()) << logical_index.error();
-            // The merged and the raw-built index both cover every segment row.
             EXPECT_EQ(logical_index.value()->stats().doc_count,
-                      static_cast<uint64_t>(segment_rows[segment_id]))
-                    << "index " << index->index_id() << " segment " << 
segment_id;
+                      static_cast<uint64_t>(segment_rows[segment_pos]))
+                    << "index " << index->index_id() << " segment " << 
segment_pos;
         }
     }
+    RowsetSharedPtr raw_rebuild;
+    const Status rebuild_status = IndexCompactionUtils::do_compaction(
+            rowsets, _engine_ref, _tablet, false, raw_rebuild, nullptr, 1000);
+    ASSERT_TRUE(rebuild_status.ok()) << rebuild_status;
+    ASSERT_NE(raw_rebuild, nullptr);
+    ASSERT_EQ(output->num_segments(), raw_rebuild->num_segments());
+    for (uint32_t segment_pos = 0; segment_pos < output->num_segments(); 
++segment_pos) {
+        EXPECT_EQ(_read_index_file_bytes(output, segment_pos),
+                  _read_index_file_bytes(raw_rebuild, segment_pos));
+    }
 }
 
 TEST_F(IndexCompactionTest, 
snii_native_merge_aborts_after_partial_destination_creation) {
diff --git 
a/be/test/storage/index/inverted/snii/compaction/snii_compaction_eligibility_test.cpp
 
b/be/test/storage/index/inverted/snii/compaction/snii_compaction_eligibility_test.cpp
index 8b58243c328..ba062c4bfdc 100644
--- 
a/be/test/storage/index/inverted/snii/compaction/snii_compaction_eligibility_test.cpp
+++ 
b/be/test/storage/index/inverted/snii/compaction/snii_compaction_eligibility_test.cpp
@@ -32,6 +32,7 @@
 #include "common/config.h"
 #include "common/status.h"
 #include "storage/index/inverted/analysis/analyzer/analyzer_provider.h"
+#include "storage/index/inverted/analysis/gram/gram_scheme.h"
 #include "storage/index/inverted/analysis/inverted_index_parser.h"
 #include "storage/index/inverted/snii/common/slice.h"
 #include "storage/index/inverted/snii/compaction/eligibility.h"
@@ -96,7 +97,9 @@ struct OpenedIndex {
     reader::LogicalIndexReader reader;
 };
 
-std::unique_ptr<OpenedIndex> open_index(const IndexShape& shape) {
+std::unique_ptr<OpenedIndex> open_index(
+        const IndexShape& shape,
+        std::optional<doris::segment_v2::gram::GramScheme> gram_scheme = 
std::nullopt) {
     std::vector<uint8_t> file_bytes;
     format::SectionRefs refs;
     if (shape.has_norms) {
@@ -122,6 +125,7 @@ std::unique_ptr<OpenedIndex> open_index(const IndexShape& 
shape) {
                                                              : 
format::IndexConfig::kDocsPositions;
     core.stats = shape.stats;
     core.section_refs = refs;
+    core.gram_scheme = gram_scheme;
     ByteSink core_frame;
     const Status core_status = format::encode_core_metadata(core, &core_frame);
     EXPECT_TRUE(core_status.ok()) << core_status.to_string();
@@ -248,6 +252,80 @@ TEST(SniiCompactionEligibilityTest, 
DestinationWritesNormsExactlyWhenAnalyzed) {
     EXPECT_FALSE(keyword.destination_writes_norms);
 }
 
+TEST(SniiCompactionEligibilityTest, 
AcceptsLegacyDocsOnlySourcesWithoutPhraseProperty) {
+    auto first = open_index(IndexShape {.tier = format::IndexTier::kT1});
+    auto second = open_index(IndexShape {.tier = format::IndexTier::kT1});
+    auto first_meta = make_index({});
+    auto second_meta = make_index({});
+    auto destination = make_index({});
+    std::vector sources {source(*first, *first_meta), source(*second, 
*second_meta)};
+    compaction::SniiCompactionEligibility eligibility;
+
+    const Status status =
+            compaction::validate_snii_compaction_eligibility(sources, 
*destination, &eligibility);
+    ASSERT_TRUE(status.ok()) << status.to_string();
+    EXPECT_FALSE(eligibility.destination_has_positions);
+    EXPECT_FALSE(eligibility.destination_writes_norms);
+}
+
+TEST(SniiCompactionEligibilityTest, RejectsDocsOnlyGramSources) {
+    auto gram = open_index(IndexShape {.tier = format::IndexTier::kT1},
+                           doris::segment_v2::gram::GramScheme {});
+    auto index_meta = make_index({{"support_phrase", "false"}});
+    std::vector sources {source(*gram, *index_meta)};
+    compaction::SniiCompactionEligibility eligibility;
+
+    expect_rejected(
+            compaction::validate_snii_compaction_eligibility(sources, 
*index_meta, &eligibility),
+            "gram scheme");
+}
+
+TEST(SniiCompactionEligibilityTest, 
AcceptsPositionedSourcesWithoutPhraseProperty) {
+    auto positioned = open_index({});
+    auto source_meta = make_index({});
+    auto destination = make_index({});
+    std::vector sources {source(*positioned, *source_meta)};
+    compaction::SniiCompactionEligibility eligibility;
+
+    const Status status =
+            compaction::validate_snii_compaction_eligibility(sources, 
*destination, &eligibility);
+    ASSERT_TRUE(status.ok()) << status.to_string();
+    EXPECT_TRUE(eligibility.destination_has_positions);
+}
+
+TEST(SniiCompactionEligibilityTest, RejectsMixedPostingShapes) {
+    auto docs_only = open_index(IndexShape {.tier = format::IndexTier::kT1});
+    auto positioned = open_index({});
+    auto first_meta = make_index({});
+    auto second_meta = make_index({});
+    auto destination = make_index({});
+    std::vector sources {source(*docs_only, *first_meta), source(*positioned, 
*second_meta)};
+    compaction::SniiCompactionEligibility eligibility;
+
+    expect_rejected(
+            compaction::validate_snii_compaction_eligibility(sources, 
*destination, &eligibility),
+            "position shape");
+}
+
+TEST(SniiCompactionEligibilityTest, 
RespectsExplicitPhrasePolicyForDocsOnlySources) {
+    auto docs_only = open_index(IndexShape {.tier = format::IndexTier::kT1});
+    auto no_phrase_meta = make_index({{"support_phrase", "false"}});
+    auto no_phrase_destination = make_index({{"support_phrase", "false"}});
+    std::vector no_phrase_sources {source(*docs_only, *no_phrase_meta)};
+    compaction::SniiCompactionEligibility eligibility;
+    ASSERT_TRUE(compaction::validate_snii_compaction_eligibility(
+                        no_phrase_sources, *no_phrase_destination, 
&eligibility)
+                        .ok());
+    EXPECT_FALSE(eligibility.destination_has_positions);
+
+    auto phrase_meta = make_index({{"support_phrase", "true"}});
+    auto phrase_destination = make_index({{"support_phrase", "true"}});
+    std::vector phrase_sources {source(*docs_only, *phrase_meta)};
+    expect_rejected(compaction::validate_snii_compaction_eligibility(
+                            phrase_sources, *phrase_destination, &eligibility),
+                    "position shape");
+}
+
 // The destination follows the norms policy shared with fresh writes: the 
"norms" property, and on
 // a variant path inverted_index_skip_norms_for_variant, which wins over the 
property.
 TEST(SniiCompactionEligibilityTest, 
DestinationWritesNormsFollowSharedNormsPolicy) {
diff --git 
a/be/test/storage/index/inverted/snii/compaction/snii_index_compaction_test.cpp 
b/be/test/storage/index/inverted/snii/compaction/snii_index_compaction_test.cpp
index b7ef12ced4b..594a45c5a5c 100644
--- 
a/be/test/storage/index/inverted/snii/compaction/snii_index_compaction_test.cpp
+++ 
b/be/test/storage/index/inverted/snii/compaction/snii_index_compaction_test.cpp
@@ -77,6 +77,13 @@ SniiIndexInput make_input(uint32_t doc_count, 
std::vector<uint32_t> null_docids,
     return input;
 }
 
+SniiIndexInput make_docs_only_input(uint32_t doc_count, std::vector<uint32_t> 
null_docids,
+                                    std::vector<TermPostings> terms) {
+    SniiIndexInput input = make_input(doc_count, std::move(null_docids), 
std::move(terms));
+    input.config = format::IndexConfig::kDocsOnly;
+    return input;
+}
+
 // T2 input with norms (A2: analyzed indexes with positions always write 
norms). Caller-supplied
 // norms must match per-document posting frequencies so compaction can rebuild 
identical bytes.
 SniiIndexInput make_norms_input(uint32_t doc_count, std::vector<uint32_t> 
null_docids,
@@ -398,6 +405,51 @@ TEST(SniiIndexCompactionTest, 
MergesDeletesNullsAndInterleavedSourcesByteIdentic
     EXPECT_EQ(merged_indexes[1].stats().null_count, 1U);
 }
 
+TEST(SniiIndexCompactionTest, MergesDocsOnlySourcesByteIdenticallyToRebuild) {
+    OpenedIndex source_zero;
+    OpenedIndex source_one;
+    build_index(make_docs_only_input(4, {1}, source_zero_terms()), 
&source_zero,
+                reader::LogicalIndexOpenMode::kCompaction);
+    build_index(make_docs_only_input(3, {2}, source_one_terms()), &source_one,
+                reader::LogicalIndexOpenMode::kCompaction);
+
+    const RowIdConversionMap conversion = {
+            {{0, 0}, {0, 2}, {1, 0}, kDeleted},
+            {{0, 1}, {0, 3}, {1, 1}},
+    };
+    const std::vector<uint32_t> destination_rows = {4, 2};
+    auto validated = make_validated_conversion(&conversion, {4, 3}, 
destination_rows);
+    ASSERT_NE(validated, nullptr);
+
+    compaction::SniiCompactionEligibility eligibility;
+    eligibility.destination_has_positions = false;
+    std::unique_ptr<SniiPlainT2MergePlan> plan;
+    assert_ok(SniiPlainT2MergePlan::prepare({&source_zero.index, 
&source_one.index}, *validated,
+                                            eligibility, 1U << 20, &plan));
+    ASSERT_NE(plan, nullptr);
+    EXPECT_EQ(plan->destination_index_config(), 
format::IndexConfig::kDocsOnly);
+
+    std::array<MemoryFile, 2> merged_files;
+    std::array<std::unique_ptr<SniiCompoundWriter>, 2> compounds;
+    std::array<SniiStreamedIndexSession*, 2> sessions = {nullptr, nullptr};
+    for (size_t i = 0; i < compounds.size(); ++i) {
+        compounds[i] = std::make_unique<SniiCompoundWriter>(&merged_files[i]);
+        SniiIndexInput input =
+                make_docs_only_input(destination_rows[i], 
plan->destination_null_docids(i), {});
+        assert_ok(compounds[i]->begin_streamed_index(std::move(input), 
&sessions[i]));
+    }
+    assert_ok(plan->execute(sessions));
+    for (auto& compound : compounds) {
+        assert_ok(compound->finish());
+    }
+
+    std::array<OpenedIndex, 2> expected;
+    build_index(make_docs_only_input(4, {2}, destination_zero_terms()), 
expected.data());
+    build_index(make_docs_only_input(2, {1}, destination_one_terms()), 
&expected[1]);
+    expect_identical_index_image(merged_files.data(), &expected[0].file);
+    expect_identical_index_image(&merged_files[1], &expected[1].file);
+}
+
 TEST(SniiIndexCompactionTest, 
MergesTwentyFourRunSourcesByteIdenticallyToReferenceRebuild) {
     constexpr size_t kSourceCount = 24;
     constexpr uint32_t kDocsPerSource = 400;


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

Reply via email to