fix(parquet): avoid seeking into the middle of a row group when prefetch and page index filtering are both enabled (#185)
* test: add test cases to show problem
* fix: avoid seek to the middle of a RowGroup when reading a parquet with prefetch=on and page-inex-filter=on
* test: update test cases
* fix: partially-matched path do not push next_row_to_read
* test: update test comments and enable multi-thread reading
* style: update comments
* clang-format
diff --git a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
index 3e8737a..defdd66 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
@@ -427,6 +427,16 @@
return Status::OK();
}
+std::optional<std::pair<uint64_t, uint64_t>> PrefetchFileBatchReaderImpl::FindReadRangeContaining(
+ size_t reader_idx, uint64_t row_id) const {
+ for (const auto& range : read_ranges_in_group_[reader_idx]) {
+ if (row_id >= range.first && row_id < range.second) {
+ return range;
+ }
+ }
+ return std::nullopt;
+}
+
Status PrefetchFileBatchReaderImpl::HandleReadResult(
size_t reader_idx, const std::pair<uint64_t, uint64_t>& read_range,
ReadBatchWithBitmap&& read_batch_with_bitmap) {
@@ -454,9 +464,18 @@
if (0 == slice_end) {
// fully out of range, data before global_row_ids has been filtered out
- readers_pos_[reader_idx]->store(global_row_ids[0]);
- ReaderUtils::ReleaseReadBatch(std::move(read_batch));
- return Status::OK();
+ // find the read range that contains the first row id and put it into queue in advance.
+ std::optional<std::pair<uint64_t, uint64_t>> owner_range =
+ FindReadRangeContaining(reader_idx, global_row_ids[0]);
+ if (owner_range == std::nullopt) {
+ readers_pos_[reader_idx]->store(global_row_ids[0]);
+ ReaderUtils::ReleaseReadBatch(std::move(read_batch));
+ return Status::OK();
+ }
+ // Recurses at most once: global_row_ids[0] is within owner_range, so the recursive
+ // call cannot compute a zero slice end again.
+ return HandleReadResult(reader_idx, owner_range.value(),
+ std::move(read_batch_with_bitmap));
} else if (slice_end < c_array->length) {
// partially out of range, data before read_range.second has been effectively consumed
readers_pos_[reader_idx]->store(read_range.second);
diff --git a/src/paimon/common/reader/prefetch_file_batch_reader_impl.h b/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
index 36673e8..f0c302e 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
@@ -134,6 +134,11 @@
Status RefreshReadRangesAfterCleanUp();
Result<std::pair<uint64_t, uint64_t>> EofRange() const;
std::optional<std::pair<uint64_t, uint64_t>> GetCurrentReadRange(size_t reader_idx) const;
+
+ /// Find the read range assigned to the given reader that contains the given file row id.
+ /// Returns nullopt when no assigned range contains it.
+ std::optional<std::pair<uint64_t, uint64_t>> FindReadRangeContaining(size_t reader_idx,
+ uint64_t row_id) const;
Status EnsureReaderPosition(size_t reader_idx,
const std::pair<uint64_t, uint64_t>& read_range) const;
Status HandleReadResult(size_t reader_idx, const std::pair<uint64_t, uint64_t>& read_range,
diff --git a/src/paimon/format/parquet/file_reader_wrapper.cpp b/src/paimon/format/parquet/file_reader_wrapper.cpp
index 4c90b95..48a4430 100644
--- a/src/paimon/format/parquet/file_reader_wrapper.cpp
+++ b/src/paimon/format/parquet/file_reader_wrapper.cpp
@@ -259,6 +259,14 @@
static_cast<uint64_t>(*original_row)
: current_filtered_rg_start_;
filtered_global_offset_ += record_batch->num_rows();
+ // Advance to the next row that survives filtering, or to the row group end when the
+ // filtered ranges are exhausted, so that next_row_to_read_ tracks the streaming position.
+ auto next_original_row =
+ current_filtered_row_ranges_.MapFilteredIndexToOriginalRow(filtered_global_offset_);
+ next_row_to_read_ =
+ next_original_row.has_value()
+ ? current_filtered_rg_start_ + static_cast<uint64_t>(*next_original_row)
+ : all_row_group_ranges_[rg_id].second;
return record_batch;
}
diff --git a/src/paimon/format/parquet/file_reader_wrapper_test.cpp b/src/paimon/format/parquet/file_reader_wrapper_test.cpp
index 3ac7f62..de3b6fd 100644
--- a/src/paimon/format/parquet/file_reader_wrapper_test.cpp
+++ b/src/paimon/format/parquet/file_reader_wrapper_test.cpp
@@ -374,6 +374,59 @@
}
}
+/// While streaming a page-filtered row group, GetNextRowToRead() must report the next row that
+/// survives filtering, and the row group end once the filtered ranges are exhausted. Reporting the
+/// row group start for the whole row group makes callers believe the reader has not moved.
+TEST_F(FileReaderWrapperTest, PageFilteredAdvancesNextRowToRead) {
+ std::string file_path = PathUtil::JoinPath(dir_->Str(), "page_next_row.parquet");
+ // 2000 rows produces 2 row groups (max_row_group_length=1000) with page index enabled.
+ PrepareParquetFile(file_path, /*row_count=*/2000, /*enable_page_index=*/true);
+ ASSERT_OK_AND_ASSIGN(auto reader_wrapper,
+ PrepareReaderWrapper(file_path, /*wrapper_batch_size=*/7));
+ ASSERT_EQ(2, reader_wrapper->GetNumberOfRowGroups());
+
+ // RowRanges are RG-local. RG0 keeps two non-contiguous stretches so that a batch can span the
+ // gap between them; RG1 keeps its first 20 rows.
+ RowRanges rg0_ranges({RowRanges::Range(10, 49), RowRanges::Range(100, 149)});
+ RowRanges rg1_ranges(RowRanges::Range(0, 19));
+ ASSERT_OK(reader_wrapper->PrepareForReading(
+ {TargetRowGroup(/*rg_index=*/0, /*is_partially_matched=*/true, /*ranges=*/rg0_ranges),
+ TargetRowGroup(/*rg_index=*/1, /*is_partially_matched=*/true, /*ranges=*/rg1_ranges)},
+ /*column_indices=*/{0, 1, 2}));
+
+ // Absolute row numbers the reader is expected to produce, in order.
+ std::vector<uint64_t> expected_rows;
+ for (uint64_t row = 10; row <= 49; ++row) {
+ expected_rows.push_back(row);
+ }
+ for (uint64_t row = 100; row <= 149; ++row) {
+ expected_rows.push_back(row);
+ }
+ for (uint64_t row = 1000; row <= 1019; ++row) {
+ expected_rows.push_back(row);
+ }
+
+ size_t consumed = 0;
+ while (true) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::RecordBatch> record_batch,
+ reader_wrapper->Next());
+ if (!record_batch) {
+ break;
+ }
+ ASSERT_LT(consumed, expected_rows.size());
+ ASSERT_EQ(expected_rows[consumed],
+ reader_wrapper->GetPreviousBatchFirstRowNumber().value());
+ consumed += record_batch->num_rows();
+ ASSERT_LE(consumed, expected_rows.size());
+ // RG0 ends exactly where RG1 starts, so the row group boundary is also covered by
+ // expected_rows; only the very last batch leaves the cursor at the file end.
+ uint64_t expected_next_row =
+ consumed < expected_rows.size() ? expected_rows[consumed] : 2000;
+ ASSERT_EQ(expected_next_row, reader_wrapper->GetNextRowToRead());
+ }
+ ASSERT_EQ(expected_rows.size(), consumed);
+}
+
TEST_F(FileReaderWrapperTest, GetRowGroupRanges) {
std::string file_path = PathUtil::JoinPath(dir_->Str(), "test.parquet");
PrepareParquetFile(file_path, /*row_count=*/5500);
diff --git a/test/inte/write_and_read_inte_test.cpp b/test/inte/write_and_read_inte_test.cpp
index 53920ba..dd63ed6 100644
--- a/test/inte/write_and_read_inte_test.cpp
+++ b/test/inte/write_and_read_inte_test.cpp
@@ -1529,6 +1529,96 @@
ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
}
+/// Reproduces the prefetch + parquet page-index filter failure: the predicate keeps only the last
+/// page of RG1, RG2 and RG3, so the prefetch reader ends up seeking to a row in the middle of a row
+/// group, which FileReaderWrapper::SeekToRow rejects.
+TEST_P(WriteAndReadInteTest, TestAppendWithParquetPageIndexFilterAndPrefetch) {
+ auto [file_format, file_system] = GetParam();
+ if (file_format != "parquet" || file_system != "local") {
+ return;
+ }
+
+ auto test_dir = UniqueTestDirectory::Create("local");
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32()),
+ arrow::field("f1", arrow::utf8())};
+ auto schema = arrow::schema(fields);
+ std::map<std::string, std::string> options = {
+ {Options::MANIFEST_FORMAT, "orc"},
+ {Options::FILE_FORMAT, "parquet"},
+ {Options::TARGET_FILE_SIZE, "1048576"},
+ {Options::BUCKET, "-1"},
+ {Options::FILE_SYSTEM, "local"},
+ // One row per page (see TestAppendWithParquetPageIndexFilter for why these three
+ // options are needed together) and 4 rows per row group, so the 16 rows below end up
+ // in 4 row groups of 4 single-row pages.
+ {Options::WRITE_BATCH_SIZE, "1"},
+ {"parquet.page.size", "1"},
+ {"parquet.enable-dictionary", "false"},
+ {"parquet.write.enable-page-index", "true"},
+ {"parquet.write.max-row-group-length", "4"},
+ {"parquet.read.enable-page-index-filter", "true"},
+ };
+ ASSERT_OK_AND_ASSIGN(
+ auto helper, TestHelper::Create(test_dir->Str(), schema, /*partition_keys=*/{},
+ /*primary_keys=*/{}, options, /*is_streaming_mode=*/true));
+ std::string table_path = test_dir->Str() + "/foo.db/bar";
+
+ std::string data = R"([
+ [0, "v0"], [1, "v1"], [2, "v2"], [3, "v3"],
+ [4, "v4"], [5, "v5"], [6, "v6"], [7, "v7"],
+ [8, "v8"], [9, "v9"], [10, "v10"], [11, "v11"],
+ [12, "v12"], [13, "v13"], [14, "v14"], [15, "v15"]
+ ])";
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<RecordBatch> batch,
+ TestHelper::MakeRecordBatch(arrow::struct_(fields), data,
+ /*partition_map=*/{}, /*bucket=*/0, {}));
+ ASSERT_OK(helper->WriteAndCommit(std::move(batch), /*commit_identifier=*/0,
+ /*expected_commit_messages=*/std::nullopt));
+
+ // Keep only the last row of RG1, RG2 and RG3, so each row group is partially matched and its
+ // first selected row is 3 rows behind the row group start.
+ auto predicate = PredicateBuilder::In(/*field_index=*/0, /*field_name=*/"f0", FieldType::INT,
+ {Literal(7), Literal(11), Literal(15)});
+ ASSERT_TRUE(predicate);
+
+ ScanContextBuilder scan_context_builder(table_path);
+ scan_context_builder.SetOptions(options)
+ .AddOption(Options::SCAN_MODE, StartupMode::LatestFull().ToString())
+ .SetPredicate(predicate);
+ ASSERT_OK_AND_ASSIGN(auto scan_context, scan_context_builder.Finish());
+ ASSERT_OK_AND_ASSIGN(auto table_scan, TableScan::Create(std::move(scan_context)));
+ ASSERT_OK_AND_ASSIGN(auto result_plan, table_scan->CreatePlan());
+ ASSERT_FALSE(result_plan->Splits().empty());
+
+ // The row group aligned read ranges still cover RG0 although the predicate pruned that row
+ // group, and 2 sub readers take the ranges round robin. The reader0 owning RG0's range
+ // therefore finds no data for it and skips ahead into the next row group it owns, whose first
+ // selected row sits in the middle of that row group. Row level filtering stays off: the
+ // expected rows below are exactly what page-index filtering selects.
+ ReadContextBuilder read_context_builder(table_path);
+ read_context_builder.SetOptions(options)
+ .SetPredicate(predicate)
+ .EnablePrefetch(true)
+ .SetPrefetchMaxParallelNum(2)
+ .SetPrefetchBatchCount(3)
+ .AddOption("test.enable-adaptive-prefetch-strategy", "false");
+ ASSERT_OK_AND_ASSIGN(auto read_context, read_context_builder.Finish());
+ ASSERT_OK_AND_ASSIGN(auto table_read, TableRead::Create(std::move(read_context)));
+ ASSERT_OK_AND_ASSIGN(auto batch_reader, table_read->CreateReader(result_plan->Splits()));
+ ASSERT_OK_AND_ASSIGN(auto read_result, ReadResultCollector::CollectResult(batch_reader.get()));
+
+ arrow::FieldVector fields_with_row_kind = fields;
+ fields_with_row_kind.insert(fields_with_row_kind.begin(),
+ arrow::field("_VALUE_KIND", arrow::int8()));
+ auto expected_data_type = arrow::struct_(fields_with_row_kind);
+ auto expected = std::make_shared<arrow::ChunkedArray>(
+ arrow::ipc::internal::json::ArrayFromJSON(expected_data_type, R"([
+[0, 7, "v7"], [0, 11, "v11"], [0, 15, "v15"]
+])")
+ .ValueOrDie());
+ ASSERT_TRUE(expected->Equals(read_result)) << read_result->ToString();
+}
+
TEST_P(WriteAndReadInteTest, TestAppendWithParquetMetadataCache) {
auto [file_format, file_system] = GetParam();
if (file_format != "parquet" || file_system != "local") {