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 40e1463f8c2 [fix](be) Validate SNII count fast-path document frequency
(#66877)
40e1463f8c2 is described below
commit 40e1463f8c21a908b0acc2f55f7a1a583a22e266
Author: Jack <[email protected]>
AuthorDate: Tue Aug 25 09:55:57 2026 +0800
[fix](be) Validate SNII count fast-path document frequency (#66877)
### What problem does this PR solve?
Issue Number: None
Related PR: #66052
Problem Summary: SNII's count-only shortcut trusted the persisted
dictionary document frequency without checking it against Core document
statistics. A corrupted 10-row segment whose posting contains two
documents but whose dictionary entry records df=100 could fabricate a
100-id bitmap in both nullable and non-nullable paths, producing an
inflated count after segment masking. Validate df against both total and
indexed document counts before fabricating the count-shaped bitmap and
report the cross-section mismatch as index corruption. Add CRC-valid
nullable and non-nullable fixtures that rewrite only df while preserving
real two-document postings.
### Release note
Fix SNII count-only queries to reject corrupted document frequencies
outside the segment document domain.
---
be/src/storage/index/snii/query/count_query.h | 31 ++-
be/src/storage/index/snii/snii_index_reader.cpp | 58 +++-
be/src/storage/index/snii/snii_index_reader.h | 30 ++-
be/src/storage/segment/column_reader.cpp | 5 +-
...inverted_index_reader_analysis_purpose_test.cpp | 6 +-
.../storage/index/snii/snii_doris_adapter_test.cpp | 4 +-
.../snii/snii_index_reader_count_fallback_test.cpp | 299 ++++++++++++++++++++-
7 files changed, 400 insertions(+), 33 deletions(-)
diff --git a/be/src/storage/index/snii/query/count_query.h
b/be/src/storage/index/snii/query/count_query.h
index bae76b4d91e..b4ed64480a1 100644
--- a/be/src/storage/index/snii/query/count_query.h
+++ b/be/src/storage/index/snii/query/count_query.h
@@ -47,22 +47,27 @@ Status count_only_term_df(const reader::LogicalIndexReader&
idx, std::string_vie
// `count` row ids DISJOINT from `nulls` (the first `count` non-null row ids,
// all < count + |nulls|). Why disjoint: the MATCH machinery unconditionally
// subtracts the segment null bitmap from every index result
-// (FunctionMatchBase -> InvertedIndexResultBitmap::mask_out_null). Real
-// postings never contain null docs -- the writer adds NO tokens for a null doc
-// (scalar add_nulls) and a NULL array row is stored as an empty range (zero
-// tokens) -- so that subtraction is a no-op on true results and df already IS
-// the exact match count regardless of nulls. A naive [0, df) range however MAY
-// collide with null row ids and be shrunk by mask_out_null; picking the ids
-// from the non-null space makes the subtraction provably a no-op, preserving
-// cardinality == df end to end.
+// (FunctionMatchBase -> InvertedIndexResultBitmap::mask_out_null). For the
+// callers allowed to fabricate, postings never contain null docs -- the writer
+// adds NO tokens for a null doc (scalar add_nulls) -- so that subtraction is a
+// no-op on true results and df already IS the exact match count regardless of
+// nulls. That does NOT hold for ARRAY columns, whose add_array_values() also
+// indexes the nested payload of outer-null rows and whose add_array_nulls()
+// never retracts it; SniiIndexReader::_try_count_only_fastpath therefore
+// refuses an ARRAY column on a segment with nulls before reaching here. A
naive
+// [0, df) range MAY collide with null row ids and be shrunk by mask_out_null;
+// picking the ids from the non-null space makes the subtraction provably a
+// no-op, preserving cardinality == df end to end.
//
// The window bound is doc-count-free: count counts only non-null docs, so
// count + |nulls| <= segment doc count and [0, count + |nulls|) always holds
-// >= count non-null ids; every fabricated id therefore stays inside the
-// segment's [0, num_rows) row space. Errors (id space would exceed the uint32
-// docid domain, or the window unexpectedly holds fewer than `count` survivors)
-// only occur on a corrupt index; callers treat them as "fall through to the
-// decode path", never as a fabricated answer.
+// >= count non-null ids. Whether that lands inside the segment's real
+// [0, num_rows) row space rests on doc count <= num_rows, which the caller
+// checks against the segment itself -- not on anything this function can see.
+// Errors (id space would exceed the uint32 docid domain, or the window
+// unexpectedly holds fewer than `count` survivors) only occur on a corrupt
+// index; callers treat them as "fall through to the decode path", never as a
+// fabricated answer.
Status fabricate_null_disjoint_count_bitmap(uint64_t count, const
roaring::Roaring& nulls,
roaring::Roaring* out);
diff --git a/be/src/storage/index/snii/snii_index_reader.cpp
b/be/src/storage/index/snii/snii_index_reader.cpp
index 3aeebe54588..97e82e027fa 100644
--- a/be/src/storage/index/snii/snii_index_reader.cpp
+++ b/be/src/storage/index/snii/snii_index_reader.cpp
@@ -1054,6 +1054,8 @@ Status SniiIndexReader::_compute_query_bitmap(const
IndexQueryContextPtr& contex
}
#endif
+// Keep the complete count-only eligibility and null-safe fabrication contract
in one linear path.
+// NOLINTNEXTLINE(readability-function-size)
Status SniiIndexReader::_try_count_only_fastpath(
const IndexQueryContextPtr& context, InvertedIndexQueryType query_type,
const InvertedIndexQueryInfo& query_info, const
std::vector<std::string>& terms,
@@ -1088,6 +1090,30 @@ Status SniiIndexReader::_try_count_only_fastpath(
&logical_reader));
}
+ // ARRAY columns: df is NOT null-free, so nothing below may fabricate from
it.
+ // ArrayColumnWriter::append_nullable hands add_array_values() every row
of the
+ // batch -- the offsets come from the nested ColumnArray, and
+ // OlapColumnDataConvertorArray::convert_to_olap reads them without ever
+ // consulting the outer null map -- and the add_array_nulls() that follows
only
+ // RECORDS the null row ids, it never retracts the tokens already emitted
for
+ // them. A nullable array whose nested payload survives under the null map
does
+ // occur: PreparedFunctionImpl::default_implementation_for_nulls documents
that
+ // nested columns keep "arbitrary values in rows corresponding to NULL
value",
+ // and need_replace_null_data_to_default() is false by default, so e.g.
+ // array_concat(arr, nullable_arr) writes arr's tokens on a NULL row. That
row
+ // then sits in a posting and is counted by df. The decode path stays
correct --
+ // mask_out_null subtracts it -- but the fabrication below deliberately
places
+ // its ids OFF the null rows, so the same subtraction removes nothing and
the
+ // count comes out too high. CLucene writes arrays identically
+ // (InvertedIndexColumnWriter::add_array_nulls only touches _null_bitmap),
so
+ // this cannot be repaired from the reader side; decline whenever this
segment
+ // has a null bitmap at all. Scalars are unaffected: ScalarColumnWriter::
+ // append_nullable splits the batch into runs and sends null runs to
+ // append_nulls(), which emits no tokens.
+ if (_column_is_array && logical_reader->section_refs().null_bitmap.length
> 0) {
+ return Status::OK();
+ }
+
std::string physical_term_scratch;
std::string_view physical_term;
bool representable = false;
@@ -1100,11 +1126,35 @@ Status SniiIndexReader::_try_count_only_fastpath(
RETURN_IF_ERROR(
::doris::snii::query::count_only_term_df(*logical_reader,
physical_term, &count));
}
+ // df bounds the fabricated bitmap, so it has to be inside a document
domain
+ // that is itself real. Two steps, because they fail differently.
+ const auto& stats = logical_reader->stats();
+ if (count > stats.doc_count || count > stats.indexed_doc_count) {
+ return Status::Error<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED, false>(
+ "SNII count fast path: term df {} exceeds document domain (doc
count {}, "
+ "indexed doc count {})",
+ count, stats.doc_count, stats.indexed_doc_count);
+ }
+ // Both limits above are CRC-valid fields of the SAME image as df, so an
image
+ // whose stats were inflated together with df clears them: on a real 10-row
+ // segment, df = doc_count = indexed_doc_count = 100 fabricates 100 ids,
and
+ // SegmentIterator -- which seeds _row_bitmap with [0, num_rows) and
intersects
+ // -- silently reports 10. The segment's own row count is the one bound the
+ // image cannot move. One-sided on purpose: an index covering FEWER rows
than
+ // the segment still fabricates ids inside [0, num_rows), so only an
oversized
+ // domain is corruption. This mirrors the equality SniiSegmentReader::
+ // load_inherited_index already demands of a rewrite.
+ if (stats.doc_count > _rows_of_segment) {
+ return Status::Error<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED, false>(
+ "SNII count fast path: index doc count {} exceeds the segment
row count {}",
+ stats.doc_count, _rows_of_segment);
+ }
- // Null handling. df is the exact match count REGARDLESS of nulls: the
- // writer adds no tokens for a null doc (scalar add_nulls; a NULL array row
- // is an empty range), so postings -- and therefore df -- never include
- // null rows, exactly matching MATCH's "null never matches" semantics. The
+ // Null handling. df is the exact match count REGARDLESS of nulls: the
writer
+ // adds no tokens for a null doc (scalar add_nulls; ARRAY columns cannot
reach
+ // this point on a segment with nulls, see the guard above), so postings
-- and
+ // therefore df -- never include null rows, exactly matching MATCH's "null
never
+ // matches" semantics. The
// fabricated bitmap however flows through FunctionMatchBase ->
// InvertedIndexResultBitmap::mask_out_null, which subtracts the segment's
// REAL null bitmap from it; a dense [0, df) range colliding with null row
diff --git a/be/src/storage/index/snii/snii_index_reader.h
b/be/src/storage/index/snii/snii_index_reader.h
index aef3d12d904..ba037b893a9 100644
--- a/be/src/storage/index/snii/snii_index_reader.h
+++ b/be/src/storage/index/snii/snii_index_reader.h
@@ -69,10 +69,21 @@ public:
using SearcherOpenObserver = void (*)(void*) noexcept;
#endif
+ // `rows_of_segment` and `column_is_array` describe the SEGMENT and the
COLUMN,
+ // deliberately not read back out of the index image: the count-only fast
path
+ // fabricates row ids, so it needs at least one bound a corrupt (but
CRC-valid)
+ // image cannot move, and one fact about how the column was written. Both
are
+ // already on hand where readers are built -- ColumnReader::_load_index
passes
+ // the same rows_of_segment to AnnIndexReader and already tests _meta_type
for
+ // OLAP_FIELD_TYPE_ARRAY a few lines above.
SniiIndexReader(const TabletIndex* index_meta,
const std::shared_ptr<IndexFileReader>& index_file_reader,
- InvertedIndexReaderType reader_type)
- : InvertedIndexReader(index_meta, index_file_reader),
_reader_type(reader_type) {}
+ InvertedIndexReaderType reader_type, uint64_t
rows_of_segment,
+ bool column_is_array)
+ : InvertedIndexReader(index_meta, index_file_reader),
+ _reader_type(reader_type),
+ _rows_of_segment(rows_of_segment),
+ _column_is_array(column_is_array) {}
Status new_iterator(std::unique_ptr<IndexIterator>* iterator) override;
Status query(const IndexQueryContextPtr& context, const std::string&
column_name,
@@ -155,8 +166,12 @@ private:
// match count, because postings never contain null docs. Falls through
// (*handled = false) for every other shape: every multi-term query
// (including phrase and OR/AND), prefix/regexp/wildcard/phrase-prefix
- // expansion. Multi-term sloppy phrases fall through with every other
- // multi-term shape; a single-term phrase remains exactly one posting df.
+ // expansion, and an ARRAY column on a segment that has nulls, whose df is
+ // NOT null-free (see the guard in the .cpp). Multi-term sloppy phrases
fall
+ // through with every other multi-term shape; a single-term phrase remains
+ // exactly one posting df. Rejects the index outright, with
+ // INVERTED_INDEX_FILE_CORRUPTED, when df or the index's document domain
+ // falls outside the segment's real row space.
// On *handled = true, query() also raises
// context->count_on_index_fastpath_hit (G03) so the SegmentIterator may
// short-circuit row emission for the count-shaped bitmap.
@@ -167,6 +182,13 @@ private:
const ::doris::snii::reader::LogicalIndexReader* preopened_reader
= nullptr);
InvertedIndexReaderType _reader_type;
+ // Row count of the segment this reader belongs to, straight from
+ // Segment::_num_rows. The count-only fast path bounds the index's own
+ // document domain against it; see _try_count_only_fastpath.
+ uint64_t _rows_of_segment = 0;
+ // True when the indexed column is an ARRAY. Disqualifies the count-only
fast
+ // path on a segment that has nulls; see _try_count_only_fastpath.
+ bool _column_is_array = false;
#ifdef BE_TEST
SingleFlightFollowerJoinedObserver _single_flight_follower_joined_observer
= nullptr;
void* _single_flight_follower_joined_opaque = nullptr;
diff --git a/be/src/storage/segment/column_reader.cpp
b/be/src/storage/segment/column_reader.cpp
index 125209a19cf..2dcb761940b 100644
--- a/be/src/storage/segment/column_reader.cpp
+++ b/be/src/storage/segment/column_reader.cpp
@@ -730,8 +730,9 @@ Status ColumnReader::_load_index(const
std::shared_ptr<IndexFileReader>& index_f
if (is_string_type(type)) {
auto reader_type = should_analyzer ?
InvertedIndexReaderType::FULLTEXT
:
InvertedIndexReaderType::STRING_TYPE;
- index_reader =
- SniiIndexReader::create_shared(index_meta,
index_file_reader, reader_type);
+ index_reader = SniiIndexReader::create_shared(
+ index_meta, index_file_reader, reader_type,
rows_of_segment,
+ _meta_type == FieldType::OLAP_FIELD_TYPE_ARRAY);
} else if (field_is_numeric_type(type)) {
index_reader = SniiBkdIndexReader::create_shared(index_meta,
index_file_reader);
} else {
diff --git
a/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp
b/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp
index eb903b1b3d3..4a0b09ba72c 100644
---
a/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp
+++
b/be/test/storage/index/inverted/inverted_index_reader_analysis_purpose_test.cpp
@@ -290,8 +290,12 @@ protected:
_snii_file_reader = std::make_shared<IndexFileReader>(
io::global_local_filesystem(),
"./ut_dir/missing_snii_analysis_purpose",
InvertedIndexStorageFormatPB::SNII);
+ // The file does not exist -- these cases only exercise the
analysis-purpose
+ // router, never the count fast path -- so the segment shape is
nominal.
_snii_reader = SniiIndexReader::create_shared(&_meta,
_snii_file_reader,
-
InvertedIndexReaderType::FULLTEXT);
+
InvertedIndexReaderType::FULLTEXT,
+ /*rows_of_segment=*/0,
+
/*column_is_array=*/false);
}
void TearDown() override {
diff --git a/be/test/storage/index/snii/snii_doris_adapter_test.cpp
b/be/test/storage/index/snii/snii_doris_adapter_test.cpp
index 16d258a0b1a..19f20e8a02c 100644
--- a/be/test/storage/index/snii/snii_doris_adapter_test.cpp
+++ b/be/test/storage/index/snii/snii_doris_adapter_test.cpp
@@ -390,7 +390,9 @@ protected:
_file_reader->_snii_segment_reader = std::move(segment_reader);
_file_reader->_inited = true;
_index_reader = SniiIndexReader::create_shared(&_meta, _file_reader,
-
InvertedIndexReaderType::FULLTEXT);
+
InvertedIndexReaderType::FULLTEXT,
+
/*rows_of_segment=*/kDocCount,
+
/*column_is_array=*/false);
_previous_query_cache =
ExecEnv::GetInstance()->get_inverted_index_query_cache();
_query_cache.reset(InvertedIndexQueryCache::create_global_cache(1024 *
1024, 1));
diff --git
a/be/test/storage/index/snii/snii_index_reader_count_fallback_test.cpp
b/be/test/storage/index/snii/snii_index_reader_count_fallback_test.cpp
index 657ff963f85..8da06a1ff7b 100644
--- a/be/test/storage/index/snii/snii_index_reader_count_fallback_test.cpp
+++ b/be/test/storage/index/snii/snii_index_reader_count_fallback_test.cpp
@@ -54,13 +54,21 @@
#include "storage/index/inverted/common_grams/common_grams_segment_metadata.h"
#include "storage/index/inverted/inverted_index_cache.h"
#include "storage/index/inverted/inverted_index_reader.h"
+#include "storage/index/snii/encoding/byte_sink.h"
+#include "storage/index/snii/encoding/crc32c.h"
+#include "storage/index/snii/format/core_metadata.h"
+#include "storage/index/snii/format/dict_block.h"
+#include "storage/index/snii/format/dict_block_directory.h"
#include "storage/index/snii/format/dict_entry.h"
+#include "storage/index/snii/format/null_bitmap.h"
#include "storage/index/snii/format/prx_pod.h"
+#include "storage/index/snii/format/sampled_term_index.h"
#include "storage/index/snii/io/local_file.h"
#include "storage/index/snii/query/bm25_scorer.h"
#include "storage/index/snii/query/phrase_query.h"
#include "storage/index/snii/query/phrase_verify_timer.h"
#include "storage/index/snii/query/query_profile.h"
+#include "storage/index/snii/query/term_query.h"
#include "storage/index/snii/snii_doris_adapter.h"
#include "storage/index/snii/snii_prx_profile.h"
// Exercise the reader router without acquiring process-global query-cache
ownership.
@@ -202,6 +210,11 @@ void init_index_meta(TabletIndex* meta, int64_t index_id =
kIndexId,
meta->init_from_pb(pb);
}
+// Rows in the segment write_positional_segment() lays down. Named because the
+// readers built over it must be told the same number: the count fast path
bounds
+// the index document domain against the segment row count.
+constexpr uint32_t kPositionalSegmentDocCount = 6;
+
void write_positional_segment() {
std::vector<doris::snii::writer::TermPostings> terms {
make_term("failed", {{.docid = 0, .positions = {0}},
@@ -223,7 +236,7 @@ void write_positional_segment() {
input.index_id = kIndexId;
input.index_suffix = "";
input.config = doris::snii::format::IndexConfig::kDocsPositions;
- input.doc_count = 6;
+ input.doc_count = kPositionalSegmentDocCount;
input.terms = std::move(terms);
input.target_dict_block_bytes = 64;
@@ -507,14 +520,24 @@ struct OpenedSniiIndex {
std::shared_ptr<SniiIndexReader> index_reader;
};
+// Opens a reader over a fixture segment. Every fixture in this file writes one
+// segment per index file with no rows beyond the indexed ones, so the index's
own
+// doc count IS the segment row count; production takes it from
Segment::_num_rows
+// instead, and that independence is exactly what the count fast path's domain
+// guard rests on -- cases that need the two to DISAGREE build their reader by
hand.
Status open_snii_index(const TabletIndex* meta, std::string index_path_prefix,
- OpenedSniiIndex* opened) {
+ OpenedSniiIndex* opened, bool column_is_array = false) {
opened->file_reader =
std::make_shared<IndexFileReader>(io::global_local_filesystem(),
std::move(index_path_prefix),
InvertedIndexStorageFormatPB::SNII);
RETURN_IF_ERROR(opened->file_reader->init());
- opened->index_reader = SniiIndexReader::create_shared(meta,
opened->file_reader,
-
InvertedIndexReaderType::FULLTEXT);
+ auto logical_reader = opened->file_reader->open_snii_index(meta);
+ if (!logical_reader.has_value()) {
+ return logical_reader.error();
+ }
+ opened->index_reader = SniiIndexReader::create_shared(
+ meta, opened->file_reader, InvertedIndexReaderType::FULLTEXT,
+ logical_reader.value()->stats().doc_count, column_is_array);
return Status::OK();
}
@@ -536,6 +559,167 @@ uint32_t lookup_df(const
doris::snii::reader::LogicalIndexReader& index, const s
return entry.df;
}
+struct CorruptDfLogicalIndex {
+ std::unique_ptr<MemoryFile> file;
+ doris::snii::reader::LogicalIndexReader reader;
+};
+
+// Rows really present in the segment the corruption fixtures describe, and the
+// number of them that are NULL when the fixture is built nullable.
+constexpr uint32_t kCorruptSegmentRows = 10;
+constexpr uint32_t kCorruptNullCount = 2;
+
+// Which numbers the CRC-valid image lies about. The POSTING is always the real
+// two documents {2, 7}, so a rejection a case observes can only come from the
+// guard under test -- never from a damaged posting. `stats_doc_count` is
written
+// into Core; leaving it at kCorruptSegmentRows keeps the document domain
honest
+// and isolates `df`, while raising it models a rewrite that inflated the
domain
+// along with df.
+struct CorruptDfSpec {
+ bool nullable = false;
+ uint32_t df = 0;
+ uint32_t stats_doc_count = kCorruptSegmentRows;
+};
+
+// Keep the CRC-valid Core/STI/DICT/DBD corruption fixture visible as one
end-to-end image builder.
+// NOLINTNEXTLINE(readability-function-size)
+Status build_corrupt_df_logical_index(const CorruptDfSpec& spec,
CorruptDfLogicalIndex* out) {
+ const uint32_t null_count = spec.nullable ? kCorruptNullCount : 0;
+
+ doris::snii::writer::SniiIndexInput input;
+ input.index_id = kIndexId;
+ input.index_suffix = "";
+ input.config = doris::snii::format::IndexConfig::kDocsPositions;
+ input.doc_count = kCorruptSegmentRows;
+ input.terms = {
+ make_term("alpha", {{.docid = 2, .positions = {0}}, {.docid = 7,
.positions = {0}}})};
+ if (spec.nullable) {
+ input.null_docids = {1, 3};
+ }
+
+ MemoryFile source_file;
+ doris::snii::writer::SniiCompoundWriter compound(&source_file);
+ RETURN_IF_ERROR(compound.add_logical_index(input));
+ RETURN_IF_ERROR(compound.finish());
+ doris::snii::reader::SniiSegmentReader source_segment;
+ RETURN_IF_ERROR(doris::snii::reader::SniiSegmentReader::open(&source_file,
&source_segment));
+ doris::snii::reader::LogicalIndexReader source_reader;
+ RETURN_IF_ERROR(source_segment.open_index(kIndexId, "", &source_reader));
+
+ std::vector<uint32_t> real_docids;
+ RETURN_IF_ERROR(doris::snii::query::term_query(source_reader, "alpha",
&real_docids));
+ DORIS_CHECK(real_docids == (std::vector<uint32_t> {2, 7}));
+
+ std::vector<doris::snii::format::DictEntry> entries;
+ uint64_t frq_base = 0;
+ uint64_t prx_base = 0;
+ RETURN_IF_ERROR(source_reader.decode_dict_block(0, &entries, &frq_base,
&prx_base));
+ DORIS_CHECK_EQ(entries.size(), 1);
+ DORIS_CHECK(entries.front().kind ==
doris::snii::format::DictEntryKind::kInline);
+ entries.front().df = spec.df;
+
+ doris::snii::format::DictBlockBuilder dict_builder(
+ source_reader.tier(), source_reader.has_positions(), frq_base,
prx_base);
+ dict_builder.add_entry(std::move(entries.front()));
+ std::vector<uint8_t> dict_block = dict_builder.finish_owned();
+
+ doris::snii::format::SampledTermIndexBuilder sampled_builder;
+ sampled_builder.add_block_first_term("alpha");
+ doris::snii::ByteSink sampled_frame;
+ sampled_builder.finish(&sampled_frame);
+
+ doris::snii::format::BlockRef block_ref;
+ block_ref.offset = 0;
+ block_ref.length = dict_block.size();
+ block_ref.n_entries = 1;
+ block_ref.checksum = doris::snii::crc32c(doris::snii::Slice(dict_block));
+ doris::snii::format::DictBlockDirectoryBuilder directory_builder;
+ directory_builder.add(block_ref);
+ doris::snii::ByteSink directory_frame;
+ directory_builder.finish(&directory_frame);
+
+ doris::snii::ByteSink null_frame;
+ if (spec.nullable) {
+ doris::snii::format::NullBitmapWriter null_writer;
+ null_writer.add_null(1);
+ null_writer.add_null(3);
+ // LogicalIndexReader::open cross-checks this against Core
stats.doc_count.
+ RETURN_IF_ERROR(null_writer.finish(spec.stats_doc_count, &null_frame));
+ }
+
+ doris::snii::format::CoreMetadata core;
+ core.index_config = doris::snii::format::IndexConfig::kDocsPositions;
+ core.stats.doc_count = spec.stats_doc_count;
+ core.stats.indexed_doc_count = spec.stats_doc_count - null_count;
+ core.stats.term_count = 1;
+ core.stats.sum_total_term_freq = 2;
+ core.stats.null_count = null_count;
+ core.section_refs.dict_region = {.offset = 0, .length = dict_block.size()};
+ if (spec.nullable) {
+ core.section_refs.null_bitmap = {.offset = dict_block.size(), .length
= null_frame.size()};
+ }
+ doris::snii::ByteSink core_frame;
+ RETURN_IF_ERROR(doris::snii::format::encode_core_metadata(core,
&core_frame));
+
+ out->file = std::make_unique<MemoryFile>();
+ RETURN_IF_ERROR(out->file->append(doris::snii::Slice(dict_block)));
+ if (spec.nullable) {
+ RETURN_IF_ERROR(out->file->append(null_frame.view()));
+ }
+ RETURN_IF_ERROR(out->file->finalize());
+ return doris::snii::reader::LogicalIndexReader::open(out->file.get(),
core_frame.view(),
+ sampled_frame.view(),
+
directory_frame.view(), &out->reader);
+}
+
+// A reader for the corruption fixtures. The corrupt logical index is handed to
+// _try_count_only_fastpath preopened, so nothing is read through
`file_reader`;
+// what matters is that the reader carries the segment's REAL row count, which
is
+// the one bound the corrupt image cannot move.
+std::shared_ptr<SniiIndexReader> make_corrupt_index_reader(
+ const TabletIndex* meta, const std::shared_ptr<IndexFileReader>&
file_reader) {
+ return SniiIndexReader::create_shared(meta, file_reader,
InvertedIndexReaderType::FULLTEXT,
+
/*rows_of_segment=*/kCorruptSegmentRows,
+ /*column_is_array=*/false);
+}
+
+// Rows in the segment write_array_null_payload_segment() lays down, and the
row
+// that is NULL at the outer level within it.
+constexpr uint32_t kArrayNullPayloadDocCount = 4;
+constexpr uint32_t kArrayNullPayloadNullDocid = 1;
+
+// A segment shaped the way a nullable ARRAY column really lands on disk when
the
+// nested payload survives under the outer null map: docid 1 is NULL, and
+// "alpha"'s posting contains it anyway. That is not a corrupt image --
+// ArrayColumnWriter::append_nullable feeds add_array_values() every row of the
+// batch (the offsets come from the nested ColumnArray, which the outer null
map
+// never touches) and the add_array_nulls() that follows only records the null
row
+// id. Reachable from SQL because
+// PreparedFunctionImpl::default_implementation_for_nulls keeps nested values
on
+// NULL rows, e.g. array_concat(arr, nullable_arr).
+Status write_array_null_payload_segment(std::string_view index_path_prefix) {
+ doris::snii::writer::SniiIndexInput input;
+ input.index_id = kIndexId;
+ input.index_suffix = "";
+ input.config = doris::snii::format::IndexConfig::kDocsPositions;
+ input.doc_count = kArrayNullPayloadDocCount;
+ input.terms = {make_term("alpha", {{.docid = kArrayNullPayloadNullDocid,
.positions = {0}},
+ {.docid = 3, .positions = {0}}})};
+ input.null_docids = {kArrayNullPayloadNullDocid};
+
+ MemoryFile memory_file;
+ doris::snii::writer::SniiCompoundWriter compound(&memory_file);
+ RETURN_IF_ERROR(compound.add_logical_index(input));
+ RETURN_IF_ERROR(compound.finish());
+
+ doris::snii::io::LocalFileWriter local_file;
+ RETURN_IF_ERROR(local_file.open(
+
InvertedIndexDescriptor::get_index_file_path_v2(std::string(index_path_prefix))));
+ RETURN_IF_ERROR(local_file.append(
+ doris::snii::Slice(memory_file.data().data(),
memory_file.data().size())));
+ return local_file.finalize();
+}
+
class SniiIndexReaderCountFallback : public testing::Test {
protected:
void SetUp() override {
@@ -547,8 +731,9 @@ protected:
std::make_shared<IndexFileReader>(io::global_local_filesystem(),
kIndexPathPrefix,
InvertedIndexStorageFormatPB::SNII);
assert_ok(_file_reader->init());
- _index_reader = SniiIndexReader::create_shared(&_meta, _file_reader,
-
InvertedIndexReaderType::FULLTEXT);
+ _index_reader = SniiIndexReader::create_shared(
+ &_meta, _file_reader, InvertedIndexReaderType::FULLTEXT,
+ /*rows_of_segment=*/kPositionalSegmentDocCount,
/*column_is_array=*/false);
_previous_query_cache =
ExecEnv::GetInstance()->get_inverted_index_query_cache();
_query_cache.reset(InvertedIndexQueryCache::create_global_cache(1024 *
1024, 1));
ExecEnv::GetInstance()->set_inverted_index_query_cache(_query_cache.get());
@@ -1469,6 +1654,103 @@ TEST_F(SniiIndexReaderCountFallback,
PublicSingleTermCountFastPathLeavesPrxStats
verify_query("failed ~1");
}
+// Runs the count fast path over a CRC-valid image whose numbers lie, and
returns
+// what it answered. The reader always knows the segment's real row count.
+Status run_count_fastpath_over_corrupt_index(const TabletIndex* meta,
+ const
std::shared_ptr<IndexFileReader>& file_reader,
+ const CorruptDfSpec& spec) {
+ CorruptDfLogicalIndex corrupt;
+ RETURN_IF_ERROR(build_corrupt_df_logical_index(spec, &corrupt));
+ auto reader = make_corrupt_index_reader(meta, file_reader);
+ QueryExecutionContext execution(/*enable_query_cache=*/false,
+ /*count_on_index_fastpath=*/true);
+ InvertedIndexQueryInfo query_info;
+ query_info.term_infos.emplace_back("alpha", 0);
+ const std::vector<std::string> terms {"alpha"};
+ bool handled = false;
+ std::shared_ptr<roaring::Roaring> bitmap;
+
+ const Status status = reader->_try_count_only_fastpath(
+ execution.context, InvertedIndexQueryType::MATCH_PHRASE_QUERY,
query_info, terms,
+ &handled, &bitmap, &corrupt.reader);
+
+ EXPECT_FALSE(handled);
+ EXPECT_EQ(bitmap, nullptr);
+ return status;
+}
+
+TEST_F(SniiIndexReaderCountFallback,
CountFastPathRejectsDfBeyondDocumentDomain) {
+ // df 100 against an honest 10-document domain.
+ const Status status = run_count_fastpath_over_corrupt_index(&_meta,
_file_reader,
+ {.nullable =
false, .df = 100});
+
+ EXPECT_TRUE(status.is<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED>()) <<
status;
+}
+
+TEST_F(SniiIndexReaderCountFallback,
CountFastPathRejectsDfBeyondNonNullDomain) {
+ // Exercises the INDEXED half of the domain check on its own: with
+ // doc_count 10 and df 9, `df > doc_count` is false, so the rejection can
only
+ // come from `df > indexed_doc_count` (10 rows minus 2 nulls = 8). Drop
that
+ // clause from the guard and this case goes red while the one above stays
green.
+ const Status status = run_count_fastpath_over_corrupt_index(&_meta,
_file_reader,
+ {.nullable =
true, .df = 9});
+
+ EXPECT_TRUE(status.is<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED>()) <<
status;
+}
+
+TEST_F(SniiIndexReaderCountFallback,
CountFastPathRejectsADocumentDomainLargerThanTheSegment) {
+ // Both in-image limits inflated together with df, which is what a rewrite
of
+ // the Core frame produces: df == doc_count == indexed_doc_count == 100
clears
+ // every comparison that stays inside the image. Only the segment's own row
+ // count (10) exposes it -- without that bound the fast path would
fabricate
+ // 100 ids and SegmentIterator, seeding [0, num_rows) and intersecting,
would
+ // quietly report 10 for a term that matches 2 documents.
+ const Status status = run_count_fastpath_over_corrupt_index(
+ &_meta, _file_reader, {.nullable = false, .df = 100,
.stats_doc_count = 100});
+
+ EXPECT_TRUE(status.is<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED>()) <<
status;
+}
+
+TEST_F(SniiIndexReaderCountFallback,
CountFastPathDeclinesAnArrayColumnHoldingANullRowInAPosting) {
+ const std::string path = std::string(kTestDir) + "/array_null_payload";
+ assert_ok(write_array_null_payload_segment(path));
+ const Field query_value =
Field::create_field<TYPE_STRING>(std::string("alpha"));
+ roaring::Roaring nulls;
+ nulls.add(kArrayNullPayloadNullDocid);
+
+ // Declared ARRAY: the fast path steps aside and the posting is decoded,
so the
+ // mask_out_null the MATCH machinery applies removes the null row -- one
match.
+ OpenedSniiIndex as_array;
+ assert_ok(open_snii_index(&_meta, path, &as_array,
/*column_is_array=*/true));
+ QueryExecutionContext decoded(/*enable_query_cache=*/false,
+ /*count_on_index_fastpath=*/true);
+ std::shared_ptr<roaring::Roaring> decoded_bitmap;
+ assert_ok(as_array.index_reader->query(decoded.context, "count_content",
query_value,
+
InvertedIndexQueryType::MATCH_PHRASE_QUERY,
+ decoded_bitmap));
+ ASSERT_NE(decoded_bitmap, nullptr);
+ EXPECT_FALSE(decoded.context->count_on_index_fastpath_hit);
+ EXPECT_EQ(bitmap_docids(*decoded_bitmap), (std::vector<uint32_t> {1, 3}));
+ EXPECT_EQ((*decoded_bitmap - nulls).cardinality(), 1U);
+
+ // Control on the SAME bytes, declared scalar. df is 2 and the fabricated
ids
+ // are placed OFF the null row on purpose, so mask_out_null removes
nothing and
+ // the count comes back 2. That gap is what the ARRAY guard exists to
close; if
+ // the writer is ever taught to skip outer-null rows, this control loses
its
+ // premise and should be retired with it.
+ OpenedSniiIndex as_scalar;
+ assert_ok(open_snii_index(&_meta, path, &as_scalar,
/*column_is_array=*/false));
+ QueryExecutionContext fabricated(/*enable_query_cache=*/false,
+ /*count_on_index_fastpath=*/true);
+ std::shared_ptr<roaring::Roaring> fabricated_bitmap;
+ assert_ok(as_scalar.index_reader->query(fabricated.context,
"count_content", query_value,
+
InvertedIndexQueryType::MATCH_PHRASE_QUERY,
+ fabricated_bitmap));
+ ASSERT_NE(fabricated_bitmap, nullptr);
+ EXPECT_TRUE(fabricated.context->count_on_index_fastpath_hit);
+ EXPECT_EQ((*fabricated_bitmap - nulls).cardinality(), 2U);
+}
+
TEST_F(SniiIndexReaderCountFallback,
CountFastPathPublishesHitAfterRequestedNullBitmap) {
const std::string path = std::string(kTestDir) + "/count_null_failure";
assert_ok(write_nullable_phrase_segment(path, /*has_null=*/true, kIndexId,
@@ -1851,8 +2133,9 @@ TEST_F(SniiIndexReaderCountFallback,
KeywordLaneWarmQueryCacheHitSkipsSegmentOpe
opened.file_reader = std::make_shared<IndexFileReader>(
io::global_local_filesystem(), kIndexPathPrefix,
InvertedIndexStorageFormatPB::SNII);
assert_ok(opened.file_reader->init());
- opened.index_reader = SniiIndexReader::create_shared(&keyword_meta,
opened.file_reader,
-
InvertedIndexReaderType::STRING_TYPE);
+ opened.index_reader = SniiIndexReader::create_shared(
+ &keyword_meta, opened.file_reader,
InvertedIndexReaderType::STRING_TYPE,
+ /*rows_of_segment=*/kPositionalSegmentDocCount,
/*column_is_array=*/false);
std::atomic<uint32_t> searcher_opens {0};
opened.index_reader->set_searcher_open_observer_for_test(record_searcher_open,
&searcher_opens);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]