This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 7f8009f8 feat(scan): read Parquet files by task range (#1002)
7f8009f8 is described below
commit 7f8009f88372a6adc950fb0ae6457e50273c2aa1
Author: kamcheungting-db <[email protected]>
AuthorDate: Tue Oct 20 20:22:01 2026 -0700
feat(scan): read Parquet files by task range (#1002)
---
src/iceberg/data/file_scan_task_reader.cc | 36 ++-
src/iceberg/data/file_scan_task_reader.h | 7 +-
src/iceberg/parquet/parquet_data_util.cc | 56 ++++
src/iceberg/parquet/parquet_data_util_internal.h | 15 +
src/iceberg/parquet/parquet_reader.cc | 81 ++++--
src/iceberg/table_scan.cc | 16 +-
src/iceberg/table_scan.h | 16 +-
src/iceberg/test/file_scan_task_reader_test.cc | 175 +++++++++++-
src/iceberg/test/file_scan_task_test.cc | 35 ++-
src/iceberg/test/parquet_test.cc | 346 ++++++++++++++++++++++-
10 files changed, 713 insertions(+), 70 deletions(-)
diff --git a/src/iceberg/data/file_scan_task_reader.cc
b/src/iceberg/data/file_scan_task_reader.cc
index 7d3a8bc7..8c55e31b 100644
--- a/src/iceberg/data/file_scan_task_reader.cc
+++ b/src/iceberg/data/file_scan_task_reader.cc
@@ -39,16 +39,21 @@ namespace iceberg {
namespace {
-ReaderOptions MakeReaderOptions(const DataFile& data_file,
std::shared_ptr<FileIO> io,
+ReaderOptions MakeReaderOptions(const FileScanTask& task,
std::shared_ptr<FileIO> io,
std::shared_ptr<Schema> projection,
std::shared_ptr<Expression> filter,
std::shared_ptr<NameMapping> name_mapping,
ReaderProperties properties,
std::optional<int64_t> first_row_id,
std::optional<int64_t> data_sequence_number) {
+ const auto& data_file = *task.data_file();
return ReaderOptions{
.path = data_file.file_path,
.length = static_cast<size_t>(data_file.file_size_in_bytes),
+ .split = task.is_split() ? std::make_optional(
+ Split{.offset =
static_cast<size_t>(task.start()),
+ .length =
static_cast<size_t>(task.length())})
+ : std::nullopt,
.io = std::move(io),
.projection = std::move(projection),
.filter = std::move(filter),
@@ -163,16 +168,29 @@ class FileScanTaskReader::Impl {
ICEBERG_PRECHECK(data_file->file_size_in_bytes >= 0,
"Data file size must not be negative: {}",
data_file->file_size_in_bytes);
+ ICEBERG_PRECHECK(std::in_range<size_t>(data_file->file_size_in_bytes),
+ "Data file size cannot be represented as size_t: {}",
+ data_file->file_size_in_bytes);
if (task.is_split()) {
- return NotSupported(
- "Reading a partial FileScanTask is not supported yet: {} "
- "(start={}, length={})",
- data_file->file_path, task.start(), task.length());
+ ICEBERG_PRECHECK(task.start() >= 0 &&
+ task.start() <= data_file->file_size_in_bytes &&
+ task.length() > 0 &&
+ task.length() <= data_file->file_size_in_bytes -
task.start(),
+ "Task range is outside file {} of size {} (start={},
length={})",
+ data_file->file_path, data_file->file_size_in_bytes,
task.start(),
+ task.length());
+ // The validated endpoints are bounded by the size_t-representable file
size.
+ if (data_file->file_format != FileFormatType::kParquet) {
+ return NotSupported(
+ "Reading a partial FileScanTask requires Parquet: {} "
+ "(start={}, length={})",
+ data_file->file_path, task.start(), task.length());
+ }
}
if (task.delete_files().empty()) {
auto options = MakeReaderOptions(
- *data_file, io_, projected_schema_, task.residual_filter(),
name_mapping_,
+ task, io_, projected_schema_, task.residual_filter(), name_mapping_,
properties_, data_file->first_row_id,
data_file->data_sequence_number);
ICEBERG_ASSIGN_OR_RAISE(
auto reader, ReaderFactoryRegistry::Open(data_file->file_format,
options));
@@ -197,9 +215,9 @@ class FileScanTaskReader::Impl {
ProjectionContext::Make(*required_schema,
*projected_schema_,
project_batch_function));
- auto options = MakeReaderOptions(
- *data_file, io_, required_schema, task.residual_filter(),
name_mapping_,
- properties_, data_file->first_row_id, data_file->data_sequence_number);
+ auto options = MakeReaderOptions(task, io_, required_schema,
task.residual_filter(),
+ name_mapping_, properties_,
data_file->first_row_id,
+ data_file->data_sequence_number);
ICEBERG_ASSIGN_OR_RAISE(auto reader,
ReaderFactoryRegistry::Open(data_file->file_format, options));
diff --git a/src/iceberg/data/file_scan_task_reader.h
b/src/iceberg/data/file_scan_task_reader.h
index c34f3e7a..33a4b82f 100644
--- a/src/iceberg/data/file_scan_task_reader.h
+++ b/src/iceberg/data/file_scan_task_reader.h
@@ -68,8 +68,11 @@ class ICEBERG_DATA_EXPORT FileScanTaskReader {
/// \brief Open a task and return an Arrow C stream for its projected live
rows.
///
- /// Partial tasks are rejected until range-aware reading is implemented. The
task and
- /// its shared data/delete-file metadata must not be mutated while the
stream uses them.
+ /// Partial tasks are supported for Parquet, with each row group assigned to
the
+ /// half-open range containing its midpoint, as in Java Iceberg. Row
positions and
+ /// inherited row IDs remain relative to the whole file. Other formats
require
+ /// whole-file tasks. The task and its shared data/delete-file metadata must
not be
+ /// mutated while the stream uses them.
Result<ArrowArrayStream> Open(const FileScanTask& task);
FileScanTaskReader(const FileScanTaskReader&) = delete;
diff --git a/src/iceberg/parquet/parquet_data_util.cc
b/src/iceberg/parquet/parquet_data_util.cc
index 449657df..73597cbf 100644
--- a/src/iceberg/parquet/parquet_data_util.cc
+++ b/src/iceberg/parquet/parquet_data_util.cc
@@ -24,6 +24,7 @@
#include <arrow/compute/api.h>
#include <arrow/record_batch.h>
#include <arrow/type.h>
+#include <parquet/metadata.h>
#include "iceberg/arrow/arrow_status_internal.h"
#include "iceberg/arrow/literal_util_internal.h"
@@ -37,6 +38,61 @@
namespace iceberg::parquet {
+Result<int64_t> RowGroupStart(const ::parquet::RowGroupMetaData& row_group,
+ int64_t file_size) {
+ if (row_group.num_columns() == 0) {
+ return Invalid("Cannot split a Parquet row group without columns");
+ }
+ auto column = row_group.ColumnChunk(0);
+ int64_t start = column->data_page_offset();
+ // A dictionary page comes before the data pages. Like parquet-java, ignore
it
+ // unless its offset is positive and earlier.
+ if (column->has_dictionary_page()) {
+ const int64_t dictionary_start = column->dictionary_page_offset();
+ if (dictionary_start > 0 && dictionary_start < start) {
+ start = dictionary_start;
+ }
+ }
+ // Byte 0 is the "PAR1" magic, so no page can start there.
+ if (start <= 0 || start >= file_size) {
+ return Invalid(
+ "Cannot split a Parquet row group with missing or out-of-file "
+ "starting page {} (file size {})",
+ start, file_size);
+ }
+ return start;
+}
+
+Result<int64_t> RowGroupMidpoint(const ::parquet::RowGroupMetaData& row_group,
+ int64_t file_size) {
+ ICEBERG_ASSIGN_OR_RAISE(auto start, RowGroupStart(row_group, file_size));
+ // The row group's pages are contiguous from its start, so its size is
bounded by the
+ // rest of the file. This bound also keeps the sum below from overflowing.
+ const int64_t max_size = file_size - start;
+ // Arrow reports an omitted row-group size as 0. Like parquet-java, fall
back to the
+ // sum of the column chunk sizes.
+ int64_t size = row_group.total_compressed_size();
+ if (size == 0) {
+ for (int i = 0; i < row_group.num_columns(); ++i) {
+ const int64_t column_size =
row_group.ColumnChunk(i)->total_compressed_size();
+ if (column_size < 0 || column_size > max_size - size) {
+ return Invalid(
+ "Cannot split a Parquet row group with invalid column {}
compressed size {} "
+ "(start {}, file size {})",
+ i, column_size, start, file_size);
+ }
+ size += column_size;
+ }
+ }
+ if (size < 0 || size > max_size) {
+ return Invalid(
+ "Cannot split a Parquet row group with invalid compressed size {} "
+ "(start {}, file size {})",
+ size, start, file_size);
+ }
+ return start + size / 2;
+}
+
namespace {
// Forward declaration
diff --git a/src/iceberg/parquet/parquet_data_util_internal.h
b/src/iceberg/parquet/parquet_data_util_internal.h
index ad70c223..afd0ea39 100644
--- a/src/iceberg/parquet/parquet_data_util_internal.h
+++ b/src/iceberg/parquet/parquet_data_util_internal.h
@@ -24,12 +24,27 @@
#include "iceberg/arrow/metadata_column_util_internal.h"
#include "iceberg/schema_util.h"
+namespace parquet {
+class RowGroupMetaData;
+} // namespace parquet
+
namespace iceberg::parquet {
/// \brief Whether direct fields need positions for _pos or inherited _row_id.
bool NeedsRowPositions(const StructType& struct_type,
const arrow::MetadataColumnContext& metadata_context);
+/// \brief Return the first column's starting page, matching parquet-java's
+/// BlockMetaData.getStartingPos(). Reject missing or out-of-file starts.
+Result<int64_t> RowGroupStart(const ::parquet::RowGroupMetaData& row_group,
+ int64_t file_size);
+
+/// \brief Return the byte offset that assigns a row group to a split: its
start plus
+/// half its compressed size, matching parquet-java's range filter used by
Java Iceberg.
+/// Reject sizes that are negative or extend past the end of the file.
+Result<int64_t> RowGroupMidpoint(const ::parquet::RowGroupMetaData& row_group,
+ int64_t file_size);
+
/// \brief Convert record batch read from a Parquet file to projected Iceberg
Schema.
///
/// \param record_batch The record batch to convert.
diff --git a/src/iceberg/parquet/parquet_reader.cc
b/src/iceberg/parquet/parquet_reader.cc
index 90fa0646..12da76a9 100644
--- a/src/iceberg/parquet/parquet_reader.cc
+++ b/src/iceberg/parquet/parquet_reader.cc
@@ -20,7 +20,9 @@
#include "iceberg/parquet/parquet_reader.h"
#include <algorithm>
+#include <limits>
#include <numeric>
+#include <utility>
#include <variant>
#include <vector>
@@ -276,7 +278,6 @@ class ParquetReader::Impl {
return InvalidArgument("Projected schema is required by Parquet reader");
}
- split_ = options.split;
read_schema_ = options.projection;
// Prepare reader properties
@@ -297,6 +298,7 @@ class ParquetReader::Impl {
ICEBERG_ARROW_ASSIGN_OR_RETURN(
reader_, ::parquet::arrow::FileReader::Make(pool_,
std::move(file_reader),
arrow_reader_properties));
+ ICEBERG_RETURN_UNEXPECTED(PlanRowGroups(options));
// Project read schema onto the Parquet file schema
ICEBERG_ASSIGN_OR_RAISE(projection_, BuildProjection(reader_.get(),
*read_schema_));
@@ -388,37 +390,62 @@ class ParquetReader::Impl {
}
private:
- Status InitReadContext() {
- context_ = std::make_unique<ReadContext>();
-
- // Row group pruning based on the split
+ Status PlanRowGroups(const ReaderOptions& options) {
// TODO(gangwu): add row group filtering based on zone map, bloom filter,
etc.
auto metadata = reader_->parquet_reader()->metadata();
- std::vector<int> row_group_indices;
- if (split_.has_value()) {
+ if (options.split.has_value()) {
+ const auto& split = *options.split;
+ ICEBERG_ARROW_ASSIGN_OR_RETURN(auto file_size, input_stream_->GetSize());
+ int64_t row_count = 0;
for (int i = 0; i < metadata->num_row_groups(); ++i) {
- auto row_group_offset = metadata->RowGroup(i)->file_offset();
- if (row_group_offset >= split_->offset &&
- row_group_offset < split_->offset + split_->length) {
- row_group_indices.push_back(i);
- } else if (row_group_offset >= split_->offset + split_->length) {
- break;
+ auto row_group = metadata->RowGroup(i);
+ // Empty row groups have no pages to assign to a split.
+ if (row_group->num_rows() == 0) {
+ continue;
+ }
+ // Like Java Iceberg, a split reads the row groups whose midpoints it
contains.
+ auto midpoint = RowGroupMidpoint(*row_group, file_size);
+ if (!midpoint.has_value()) {
+ return Invalid(
+ "Cannot read Parquet split {} (start={}, length={}), row "
+ "group {}: {}",
+ options.path, split.offset, split.length, i,
midpoint.error().message);
+ }
+ // Subtraction avoids overflow for a caller-supplied unbounded split
length.
+ if (std::cmp_greater_equal(*midpoint, split.offset) &&
+ static_cast<uint64_t>(*midpoint) - split.offset < split.length) {
+ row_group_indices_.push_back(i);
+ }
+ if (row_group->num_rows() < 0 ||
+ row_group->num_rows() > std::numeric_limits<int64_t>::max() -
row_count) {
+ return Invalid("Invalid Parquet row count in file {}, row group {}",
+ options.path, i);
}
+ row_count += row_group->num_rows();
}
- } else {
- row_group_indices.resize(metadata->num_row_groups());
- std::iota(row_group_indices.begin(), row_group_indices.end(), 0); //
NOLINT
+ } else if (metadata->num_row_groups() > 0) {
+ row_group_indices_.resize(metadata->num_row_groups());
+ std::iota(row_group_indices_.begin(), row_group_indices_.end(), 0); //
NOLINT
}
+ return {};
+ }
+ Status InitReadContext() {
+ // Build locally so initialization can be retried without consuming any
rows.
+ auto context = std::make_unique<ReadContext>();
// Create the record batch reader, including for empty splits to preserve
the schema.
- auto column_indices = SelectedColumnIndices(projection_);
ICEBERG_ARROW_ASSIGN_OR_RETURN(
- context_->record_batch_reader_,
- reader_->GetRecordBatchReader(row_group_indices, column_indices));
+ context->record_batch_reader_,
+ reader_->GetRecordBatchReader(row_group_indices_,
+ SelectedColumnIndices(projection_)));
if (NeedsRowPositions(*read_schema_, metadata_context_)) {
- ICEBERG_ASSIGN_OR_RAISE(context_->row_number_reader_,
- RowNumberReader::Make(*metadata,
row_group_indices, pool_));
+ auto metadata = reader_->parquet_reader()->metadata();
+ // Share the batch reader's selection so positions remain file-relative
even
+ // when a batch spans nonconsecutive row groups.
+ ICEBERG_ASSIGN_OR_RAISE(
+ context->row_number_reader_,
+ RowNumberReader::Make(*metadata, row_group_indices_, pool_));
}
// Build the output Arrow schema from the projected Iceberg schema. This
schema is the
@@ -426,7 +453,7 @@ class ParquetReader::Impl {
// the schema of the file.
ArrowSchema arrow_schema;
ICEBERG_RETURN_UNEXPECTED(ToArrowSchema(*read_schema_, &arrow_schema));
- ICEBERG_ARROW_ASSIGN_OR_RETURN(context_->output_arrow_schema_,
+ ICEBERG_ARROW_ASSIGN_OR_RETURN(context->output_arrow_schema_,
::arrow::ImportSchema(&arrow_schema));
// Align the output schema with the arrays the reader actually produces.
The reader's
@@ -437,18 +464,18 @@ class ParquetReader::Impl {
// 3. Mixed list and large_list types in files with stored schemas
// For each projected field, we use the reader's actual type. For missing
fields
// (columns not in the file), we apply the configured use_large_list
preference.
- context_->output_arrow_schema_ = AlignOutputSchemaToReaderSchema(
- context_->output_arrow_schema_,
context_->record_batch_reader_->schema(),
+ context->output_arrow_schema_ = AlignOutputSchemaToReaderSchema(
+ context->output_arrow_schema_, context->record_batch_reader_->schema(),
projection_, use_large_list_);
-
+ context_ = std::move(context);
return {};
}
private:
// TODO(gangwu): make memory pool configurable
::arrow::MemoryPool* pool_ = ::arrow::default_memory_pool();
- // The split to read from the Parquet file.
- std::optional<Split> split_;
+ // Row groups in footer order, shared by the batch and row-number readers.
+ std::vector<int> row_group_indices_;
// Whether to read list columns as large_list (64-bit offsets).
bool use_large_list_ = false;
// Schema to read from the Parquet file.
diff --git a/src/iceberg/table_scan.cc b/src/iceberg/table_scan.cc
index 25548bc7..d04e3385 100644
--- a/src/iceberg/table_scan.cc
+++ b/src/iceberg/table_scan.cc
@@ -224,7 +224,7 @@ Result<std::shared_ptr<FileScanTask>>
FileScanTask::MakeSplit(
return std::shared_ptr<FileScanTask>(
new FileScanTask(std::move(data_file), std::move(delete_files),
std::move(filter),
- Range{.start = start, .length = length, .file_size =
file_size}));
+ Range{.start = start, .length = length}));
}
int64_t FileScanTask::length() const {
@@ -241,16 +241,20 @@ int64_t FileScanTask::estimated_row_count() const {
}
const int64_t record_count = data_file_->record_count;
- if (record_count <= 0) {
+ const int64_t file_size = data_file_->file_size_in_bytes;
+ if (record_count <= 0 || file_size <= 0 || range_->start > file_size ||
+ range_->length > file_size - range_->start) {
+ // Keep arithmetic safe even for tasks invalidated by metadata mutation.
return 0;
}
- // floor(end * rows / size) - floor(start * rows / size), so splits that
cover
- // the file sum exactly to record_count. int128 avoids overflow.
+ // floor(end * rows / size) - floor(start * rows / size). For non-overlapping
+ // splits covering a file with unchanged metadata, estimates sum to
record_count.
+ // int128 avoids overflow.
const auto end = static_cast<int128_t>(range_->start) + range_->length;
- const auto count_at_end = end * record_count / range_->file_size;
+ const auto count_at_end = end * record_count / file_size;
const auto count_at_start =
- static_cast<int128_t>(range_->start) * record_count / range_->file_size;
+ static_cast<int128_t>(range_->start) * record_count / file_size;
return static_cast<int64_t>(count_at_end - count_at_start);
}
diff --git a/src/iceberg/table_scan.h b/src/iceberg/table_scan.h
index 7afaa79d..1307089e 100644
--- a/src/iceberg/table_scan.h
+++ b/src/iceberg/table_scan.h
@@ -72,8 +72,8 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
/// \param data_file The data file to read.
/// \param delete_files Delete files that apply to this data file.
/// \param filter Optional residual filter to apply after reading.
- /// \note The data file, delete files, and residual filter must not be
mutated while
- /// a planning or reading stream uses this task.
+ /// \note The shared data file, delete files, and residual filter must remain
+ /// unchanged for this task's lifetime. Metadata changes require rebuilding
tasks.
explicit FileScanTask(std::shared_ptr<DataFile> data_file,
std::vector<std::shared_ptr<DataFile>> delete_files =
{},
std::shared_ptr<Expression> filter = nullptr);
@@ -82,8 +82,8 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
///
/// A range covering the entire file is represented as a whole-file task. An
empty
/// range is valid only for an empty whole file. The data file, delete
files, and
- /// residual filter are shared with the resulting task and must not be
mutated
- /// while a planning or reading stream uses it.
+ /// residual filter are shared with the resulting task and must remain
unchanged
+ /// for its lifetime. Metadata changes require rebuilding tasks.
static Result<std::shared_ptr<FileScanTask>> MakeSplit(
std::shared_ptr<DataFile> data_file, int64_t start, int64_t length,
std::vector<std::shared_ptr<DataFile>> delete_files = {},
@@ -92,7 +92,7 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
/// \brief The inclusive byte offset where this task starts, or zero for a
whole file.
int64_t start() const { return range_ ? range_->start : 0; }
- /// \brief The byte length of this task, or the current size of a whole file.
+ /// \brief The byte length of this task, or the size of a whole file.
int64_t length() const;
/// \brief Whether this task reads a proper subset of its data file.
@@ -112,6 +112,10 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
Kind kind() const override { return Kind::kFileScanTask; }
int64_t size_bytes() const override;
int32_t files_count() const override;
+
+ /// \brief The estimated number of rows in this task, prorated by byte range.
+ /// For valid, non-overlapping splits covering a file with unchanged
metadata,
+ /// the estimates sum to the file's record_count.
int64_t estimated_row_count() const override;
private:
@@ -119,8 +123,6 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
struct Range {
int64_t start;
int64_t length;
- // Size when the range was validated. Always positive, so safe to divide
by.
- int64_t file_size;
};
FileScanTask(std::shared_ptr<DataFile> data_file,
diff --git a/src/iceberg/test/file_scan_task_reader_test.cc
b/src/iceberg/test/file_scan_task_reader_test.cc
index 11366f72..05416bd7 100644
--- a/src/iceberg/test/file_scan_task_reader_test.cc
+++ b/src/iceberg/test/file_scan_task_reader_test.cc
@@ -19,9 +19,12 @@
#include "iceberg/data/file_scan_task_reader.h"
+#include <algorithm>
+#include <array>
#include <memory>
#include <optional>
#include <string>
+#include <tuple>
#include <utility>
#include <vector>
@@ -31,11 +34,13 @@
#include <arrow/record_batch.h>
#include <arrow/table.h>
#include <parquet/arrow/writer.h>
+#include <parquet/file_reader.h>
#include <parquet/metadata.h>
#include <parquet/properties.h>
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/arrow/arrow_register.h"
+#include "iceberg/arrow/arrow_status_internal.h"
#include "iceberg/arrow_c_data.h"
#include "iceberg/arrow_c_data_guard_internal.h"
#include "iceberg/arrow_c_data_util_internal.h"
@@ -56,6 +61,7 @@
#include "iceberg/schema_internal.h"
#include "iceberg/table_scan.h"
#include "iceberg/test/matchers.h"
+#include "iceberg/test/std_io.h"
#include "iceberg/test/temp_file_test_base.h"
#include "iceberg/type.h"
#include "iceberg/util/macros.h"
@@ -121,6 +127,31 @@ class FileScanTaskReaderTest : public TempFileTestBase {
table_schema_->schema_id());
}
+ using RowWithPositionAndLineage = std::array<int64_t, 4>;
+
+ Result<std::vector<RowWithPositionAndLineage>> ReadRows(FileScanTaskReader&
reader,
+ const FileScanTask&
task) {
+ ICEBERG_ASSIGN_OR_RAISE(auto stream, reader.Open(task));
+ ICEBERG_ARROW_ASSIGN_OR_RETURN(auto batches,
+ ::arrow::ImportRecordBatchReader(&stream));
+ std::vector<RowWithPositionAndLineage> rows;
+ while (true) {
+ ICEBERG_ARROW_ASSIGN_OR_RETURN(auto batch, batches->Next());
+ if (batch == nullptr) {
+ return rows;
+ }
+ ICEBERG_ARROW_RETURN_NOT_OK(batch->ValidateFull());
+ const auto& ids = static_cast<const
::arrow::Int32Array&>(*batch->column(0));
+ const auto& positions = static_cast<const
::arrow::Int64Array&>(*batch->column(1));
+ const auto& row_ids = static_cast<const
::arrow::Int64Array&>(*batch->column(2));
+ const auto& sequences = static_cast<const
::arrow::Int64Array&>(*batch->column(3));
+ for (int64_t i = 0; i < batch->num_rows(); ++i) {
+ rows.push_back(
+ {ids.Value(i), positions.Value(i), row_ids.Value(i),
sequences.Value(i)});
+ }
+ }
+ }
+
Result<ExportedBatch> MakeBatch(const Schema& schema,
const std::string& json_data) const {
ICEBERG_ASSIGN_OR_RAISE(auto arrow_schema, MakeArrowSchema(schema));
@@ -409,7 +440,7 @@ TEST_F(FileScanTaskReaderTest,
OpenWithoutDeletesReadsProjectedSchema) {
VerifyStream(&stream, R"([[1, "Foo"], [2, "Bar"], [3, "Baz"]])"));
}
-TEST_F(FileScanTaskReaderTest, RejectsPartialTaskUntilRangeReadsAreSupported) {
+TEST_F(FileScanTaskReaderTest, RejectsPartialTasksForUnsupportedFormats) {
ICEBERG_UNWRAP_OR_FAIL(
auto data_file,
MakeDataFile(table_schema_,
@@ -423,11 +454,147 @@ TEST_F(FileScanTaskReaderTest,
RejectsPartialTaskUntilRangeReadsAreSupported) {
.projected_schema = projected_schema_,
};
ICEBERG_UNWRAP_OR_FAIL(auto reader,
FileScanTaskReader::Make(std::move(options)));
- auto result = reader->Open(*task);
- EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
- EXPECT_THAT(result, HasErrorMessage("Reading a partial FileScanTask"));
+ for (auto format : {FileFormatType::kAvro, FileFormatType::kOrc}) {
+ data_file->file_format = format;
+ auto result = reader->Open(*task);
+ EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
+ EXPECT_THAT(result,
+ HasErrorMessage("Reading a partial FileScanTask requires
Parquet"));
+ }
+}
+
+TEST_F(FileScanTaskReaderTest, RechecksSplitBoundsAfterFileSizeChanges) {
+ ICEBERG_UNWRAP_OR_FAIL(auto data_file,
+ MakeDataFile(table_schema_, R"([[1, "a", "b"]])", 1));
+ const int64_t file_size = data_file->file_size_in_bytes;
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTask::MakeSplit(data_file, 4,
file_size - 4));
+ ICEBERG_UNWRAP_OR_FAIL(auto reader, FileScanTaskReader::Make({
+ .io = file_io_,
+ .table_schema = table_schema_,
+ .projected_schema =
projected_schema_,
+ }));
+ for (int64_t size : {file_size - 1, int64_t{3}, int64_t{0}}) {
+ data_file->file_size_in_bytes = size;
+ auto result = reader->Open(*task);
+ EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(result, HasErrorMessage("Task range is outside file"));
+ }
+ data_file->file_size_in_bytes = -1;
+ EXPECT_THAT(reader->Open(*task),
+ HasErrorMessage("Data file size must not be negative"));
+}
+
+TEST_F(FileScanTaskReaderTest, EmptyParquetFileAndSplitReturnNoRows) {
+ ICEBERG_UNWRAP_OR_FAIL(auto data_file, MakeDataFile(table_schema_, "[]", 0));
+ ICEBERG_UNWRAP_OR_FAIL(auto reader, FileScanTaskReader::Make({
+ .io = file_io_,
+ .table_schema = table_schema_,
+ .projected_schema =
projected_schema_,
+ }));
+ ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 0, 1));
+ for (const auto& task : {std::make_shared<FileScanTask>(data_file), split}) {
+ ICEBERG_UNWRAP_OR_FAIL(auto stream, reader->Open(*task));
+ auto batches = ::arrow::ImportRecordBatchReader(&stream).ValueOrDie();
+ auto batch = batches->Next();
+ ASSERT_TRUE(batch.ok()) << batch.status();
+ ASSERT_EQ(batch.ValueOrDie(), nullptr);
+ }
+}
+
+class FileScanTaskReaderSplitTest
+ : public FileScanTaskReaderTest,
+ public ::testing::WithParamInterface<std::tuple<std::string, int64_t>>
{};
+
+TEST_P(FileScanTaskReaderSplitTest,
SplitsMatchWholeFileWithAbsolutePositionsAndLineage) {
+ const auto& [delete_kind, batch_size] = GetParam();
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto data_file,
+ MakeDataFile(table_schema_,
+ std::vector<std::string>{R"([[1, "a", "keep"], [2, "b",
"delete"]])",
+ R"([[3, "c", "delete"], [4, "d",
"delete"]])",
+ R"([[5, "e", "keep"], [6, "f",
"delete"]])"},
+ /*record_count=*/6, /*max_row_group_length=*/2));
+ data_file->first_row_id = 100;
+ data_file->data_sequence_number = 7;
+ std::vector<std::shared_ptr<DataFile>> deletes;
+ if (delete_kind == "position" || delete_kind == "mixed") {
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto deletion,
MakePositionDeleteFile(CreateNewTempFilePathWithSuffix(".parquet"),
+ {1, 2, 3, 5},
data_file->file_path));
+ deletes.push_back(std::move(deletion));
+ }
+ if (delete_kind == "equality" || delete_kind == "mixed") {
+ // The equality field is absent from the output projection.
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto deletion,
+ MakeEqualityDeleteFile(CreateNewTempFilePathWithSuffix(".parquet"),
table_schema_,
+ R"([[0, "", "delete"]])", {3}));
+ deletes.push_back(std::move(deletion));
+ }
+ if (delete_kind == "dv") {
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto deletion,
MakeDeletionVectorFile(CreateNewTempFilePathWithSuffix(".puffin"),
+ {1, 2, 3, 5},
data_file->file_path));
+ deletes.push_back(std::move(deletion));
+ }
+ auto projection = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
MetadataColumns::kRowPosition,
+ MetadataColumns::kRowId, MetadataColumns::kLastUpdatedSequenceNumber});
+ // This FileIO honors the supplied length hint, so a split length passed as
the
+ // file length would make the footer unreadable.
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader, FileScanTaskReader::Make({
+ .io = std::make_shared<test::StdFileIO>(),
+ .table_schema = table_schema_,
+ .projected_schema = projection,
+ .properties = {{"read.batch-size",
std::to_string(batch_size)}},
+ }));
+
+ std::vector<RowWithPositionAndLineage> expected;
+ for (int64_t id = 1; id <= 6; ++id) {
+ if (delete_kind == "none" || id == 1 || id == 5) {
+ expected.push_back({id, id - 1, 100 + id - 1, 7});
+ }
+ }
+ ICEBERG_UNWRAP_OR_FAIL(auto whole_rows,
+ ReadRows(*reader, FileScanTask(data_file, deletes)));
+ ASSERT_EQ(whole_rows, expected);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
data_file->file_path));
+ auto metadata = ::parquet::ReadMetaData(input);
+ ASSERT_EQ(metadata->num_row_groups(), 3);
+ std::vector<int64_t> boundaries{0, 1, data_file->file_size_in_bytes};
+ for (int i = 0; i < metadata->num_row_groups(); ++i) {
+ auto column = metadata->RowGroup(i)->ColumnChunk(0);
+ ASSERT_TRUE(column->has_dictionary_page());
+ ASSERT_LT(column->dictionary_page_offset(), column->data_page_offset());
+ // Include exact starts, ranges containing no starts, and boundaries
inside groups.
+ boundaries.push_back(column->dictionary_page_offset());
+ boundaries.push_back(column->dictionary_page_offset() + 1);
+ boundaries.push_back(column->data_page_offset());
+ }
+ std::ranges::sort(boundaries);
+ auto duplicates = std::ranges::unique(boundaries);
+ boundaries.erase(duplicates.begin(), duplicates.end());
+ std::vector<RowWithPositionAndLineage> split_rows;
+ for (size_t i = 1; i < boundaries.size(); ++i) {
+ SCOPED_TRACE(i);
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto split, FileScanTask::MakeSplit(data_file, boundaries[i - 1],
+ boundaries[i] - boundaries[i - 1],
deletes));
+ ICEBERG_UNWRAP_OR_FAIL(auto rows, ReadRows(*reader, *split));
+ split_rows.insert(split_rows.end(), rows.begin(), rows.end());
+ }
+ EXPECT_EQ(split_rows, whole_rows);
+ EXPECT_EQ(split_rows, expected);
}
+INSTANTIATE_TEST_SUITE_P(DeleteKindsAndBatchSizes, FileScanTaskReaderSplitTest,
+ ::testing::Combine(::testing::Values("none",
"position",
+ "equality",
"dv", "mixed"),
+ ::testing::Values(int64_t{1},
int64_t{100})));
+
TEST_F(FileScanTaskReaderTest, ReadLastUpdatedFromDataSeq) {
ICEBERG_UNWRAP_OR_FAIL(
auto data_file,
diff --git a/src/iceberg/test/file_scan_task_test.cc
b/src/iceberg/test/file_scan_task_test.cc
index 6394c96f..41861cae 100644
--- a/src/iceberg/test/file_scan_task_test.cc
+++ b/src/iceberg/test/file_scan_task_test.cc
@@ -64,9 +64,10 @@ TEST(FileScanTaskRangeTest, WholeFileAndEmptyFile) {
EXPECT_EQ(explicit_whole->length(), 100);
EXPECT_EQ(explicit_whole->estimated_row_count(), 7);
- data_file->file_size_in_bytes = 0;
- data_file->record_count = 0;
- ICEBERG_UNWRAP_OR_FAIL(auto empty, FileScanTask::MakeSplit(data_file, 0, 0));
+ auto empty_file = std::make_shared<DataFile>();
+ empty_file->file_size_in_bytes = 0;
+ empty_file->record_count = 0;
+ ICEBERG_UNWRAP_OR_FAIL(auto empty, FileScanTask::MakeSplit(empty_file, 0,
0));
EXPECT_FALSE(empty->is_split());
EXPECT_EQ(empty->start(), 0);
EXPECT_EQ(empty->length(), 0);
@@ -155,6 +156,34 @@ TEST(FileScanTaskRangeTest, RowEstimateDoesNotOverflow) {
EXPECT_EQ(last->estimated_row_count(), 1);
}
+TEST(FileScanTaskRangeTest, RowEstimateIsBoundedForInvalidatedRanges) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_path = "test.parquet";
+ data_file->file_size_in_bytes = 100;
+ data_file->record_count = std::numeric_limits<int64_t>::max();
+ ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 40,
60));
+
+ // Mutation violates the task's lifetime contract, but the estimate should
still
+ // avoid overflow and division by zero when the range is invalidated.
+ for (int64_t file_size : {99, 40, 39, 1, 0, -1}) {
+ SCOPED_TRACE(file_size);
+ data_file->file_size_in_bytes = file_size;
+ EXPECT_EQ(split->estimated_row_count(), 0);
+ }
+}
+
+TEST(FileScanTaskRangeTest, RowEstimatesHandleNonPositiveRecordCounts) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_size_in_bytes = 100;
+
+ for (int64_t record_count : {0, -1}) {
+ SCOPED_TRACE(record_count);
+ data_file->record_count = record_count;
+ ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 0,
40));
+ EXPECT_EQ(split->estimated_row_count(), 0);
+ }
+}
+
class FileScanTaskTest : public TempFileTestBase {
protected:
static void SetUpTestSuite() { parquet::RegisterAll(); }
diff --git a/src/iceberg/test/parquet_test.cc b/src/iceberg/test/parquet_test.cc
index 9fbeae87..1327f03f 100644
--- a/src/iceberg/test/parquet_test.cc
+++ b/src/iceberg/test/parquet_test.cc
@@ -39,7 +39,9 @@
#include <parquet/arrow/reader.h>
#include <parquet/arrow/writer.h>
#include <parquet/file_reader.h>
+#include <parquet/file_writer.h>
#include <parquet/metadata.h>
+#include <parquet/schema.h>
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/arrow/arrow_status_internal.h"
@@ -47,6 +49,7 @@
#include "iceberg/file_reader.h"
#include "iceberg/file_writer.h"
#include "iceberg/metadata_columns.h"
+#include "iceberg/parquet/parquet_data_util_internal.h"
#include "iceberg/parquet/parquet_register.h"
#include "iceberg/result.h"
#include "iceberg/schema.h"
@@ -55,7 +58,6 @@
#include "iceberg/test/matchers.h"
#include "iceberg/test/std_io.h"
#include "iceberg/test/temp_file_test_base.h"
-#include "iceberg/test/test_resource.h"
#include "iceberg/type.h"
#include "iceberg/util/checked_cast.h"
#include "iceberg/util/macros.h"
@@ -158,6 +160,31 @@ std::optional<ParquetCodec> FirstUnavailableParquetCodec()
{
return std::nullopt;
}
+std::unique_ptr<::parquet::FileMetaData> MakeMetadataWithPageOffsets(
+ const std::vector<std::pair<int64_t, std::optional<int64_t>>>&
page_offsets) {
+ ::parquet::schema::NodeVector fields;
+ for (size_t i = 0; i < page_offsets.size(); ++i) {
+ fields.push_back(::parquet::schema::PrimitiveNode::Make(
+ "col" + std::to_string(i), ::parquet::Repetition::REQUIRED, nullptr,
+ ::parquet::Type::INT32, /*primitive_length=*/-1, /*field_id=*/i + 1));
+ }
+ ::parquet::SchemaDescriptor schema;
+ schema.Init(::parquet::schema::GroupNode::Make(
+ "schema", ::parquet::Repetition::REQUIRED, fields));
+ auto builder = ::parquet::FileMetaDataBuilder::Make(
+ &schema, ::parquet::default_writer_properties());
+ auto row_group = builder->AppendRowGroup();
+ row_group->set_num_rows(1);
+ for (const auto& [data_offset, dictionary_offset] : page_offsets) {
+ row_group->NextColumnChunk()->Finish(
+ /*num_values=*/1, dictionary_offset.value_or(0),
/*index_page_offset=*/0,
+ data_offset, /*compressed_size=*/10, /*uncompressed_size=*/10,
+ dictionary_offset.has_value(), /*dictionary_fallback=*/false, {}, {});
+ }
+ row_group->Finish(10 * page_offsets.size());
+ return builder->Finish();
+}
+
constexpr std::array<uint8_t, Uuid::kLength> kUuidBytes1 = {
0x12, 0x3e, 0x45, 0x67, 0xe8, 0x9b, 0x12, 0xd3,
0xa4, 0x56, 0x42, 0x66, 0x14, 0x17, 0x40, 0x00};
@@ -167,6 +194,45 @@ constexpr std::array<uint8_t, Uuid::kLength> kUuidBytes2 =
{
} // namespace
+TEST(ParquetSplitTest, CanonicalStartUsesFirstColumnPages) {
+ for (auto dictionary_offset :
+ {std::optional<int64_t>{}, std::optional<int64_t>{0},
std::optional<int64_t>{80},
+ std::optional<int64_t>{100}, std::optional<int64_t>{120}}) {
+ // The second column starts earlier and must not be used as the assignment
key.
+ auto metadata = MakeMetadataWithPageOffsets({{100, dictionary_offset},
{50, 40}});
+ const int64_t expected = dictionary_offset == 80 ? 80 : 100;
+ EXPECT_THAT(RowGroupStart(*metadata->RowGroup(0), 200),
+ HasValue(::testing::Eq(expected)));
+ }
+}
+
+TEST(ParquetSplitTest, CanonicalStartRejectsMissingOrOutOfFilePages) {
+ for (int64_t offset : {-1, 0, 200, 201}) {
+ auto metadata = MakeMetadataWithPageOffsets({{offset, std::nullopt}});
+ EXPECT_THAT(RowGroupStart(*metadata->RowGroup(0), 200),
IsError(ErrorKind::kInvalid));
+ }
+ auto no_columns = MakeMetadataWithPageOffsets({});
+ EXPECT_THAT(RowGroupStart(*no_columns->RowGroup(0), 200),
+ HasErrorMessage("without columns"));
+}
+
+TEST(ParquetSplitTest, MidpointAddsHalfTheRowGroupSize) {
+ // Each column chunk is 10 bytes, so the row group is 20 bytes from its
start at 80.
+ auto metadata = MakeMetadataWithPageOffsets({{100, 80}, {50, 40}});
+ ASSERT_EQ(metadata->RowGroup(0)->total_compressed_size(), 20);
+ EXPECT_THAT(RowGroupMidpoint(*metadata->RowGroup(0), 200),
HasValue(::testing::Eq(90)));
+ // The size may reach, but not pass, the end of the file.
+ EXPECT_THAT(RowGroupMidpoint(*metadata->RowGroup(0), 100),
HasValue(::testing::Eq(90)));
+ EXPECT_THAT(RowGroupMidpoint(*metadata->RowGroup(0), 99),
+ HasErrorMessage("invalid compressed size 20"));
+}
+
+TEST(ParquetSplitTest, MidpointRejectsInvalidStart) {
+ auto metadata = MakeMetadataWithPageOffsets({{0, std::nullopt}});
+ EXPECT_THAT(RowGroupMidpoint(*metadata->RowGroup(0), 200),
+ HasErrorMessage("starting page 0"));
+}
+
class ParquetReaderTest : public TempFileTestBase {
protected:
static void SetUpTestSuite() { parquet::RegisterAll(); }
@@ -251,7 +317,7 @@ class ParquetReaderTest : public TempFileTestBase {
.properties =
std::move(writer_properties)}));
}
- void CreateSplitParquetFile() {
+ void CreateSplitParquetFile(int64_t row_group_size = 2, bool dictionary =
true) {
const std::string kParquetFieldIdKey = "PARQUET:field_id";
auto arrow_schema = ::arrow::schema(
{::arrow::field("id", ::arrow::int32(), /*nullable=*/false,
@@ -270,12 +336,30 @@ class ParquetReaderTest : public TempFileTestBase {
auto io = internal::checked_cast<arrow::ArrowFileSystemFileIO&>(*file_io_);
auto outfile = io.fs()->OpenOutputStream(temp_parquet_file_).ValueOrDie();
+ ::parquet::WriterProperties::Builder properties;
+ if (!dictionary) {
+ properties.disable_dictionary();
+ }
ASSERT_TRUE(::parquet::arrow::WriteTable(*table,
::arrow::default_memory_pool(),
- outfile, /*chunk_size=*/2)
+ outfile, row_group_size,
properties.build())
.ok());
ASSERT_TRUE(outfile->Close().ok());
}
+ void ReorderRowGroups(const std::vector<int>& row_groups) {
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto metadata = ::parquet::ReadMetaData(input);
+ auto file_size = input->GetSize().ValueOrDie();
+ auto pages = input->ReadAt(0, file_size - metadata->size() -
8).ValueOrDie();
+ ASSERT_TRUE(input->Close().ok());
+ ICEBERG_UNWRAP_OR_FAIL(auto output,
+ arrow::OpenArrowOutputStream(file_io_,
temp_parquet_file_));
+ ASSERT_TRUE(output->Write(pages).ok());
+ ::parquet::WriteFileMetaData(*metadata->Subset(row_groups), output.get());
+ ASSERT_TRUE(output->Close().ok());
+ }
+
std::shared_ptr<Schema> RowLineageSchema() {
return std::make_shared<Schema>(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
@@ -1009,15 +1093,24 @@ TEST_F(ParquetReaderTest,
EmptySplitKeepsPrunedNestedListTypes) {
TEST_F(ParquetReaderTest, ReadSplit) {
CreateSplitParquetFile();
- // Read split offsets
+ // Read row-group starts and midpoints
auto io = internal::checked_cast<arrow::ArrowFileSystemFileIO&>(*file_io_);
auto input_stream = io.fs()->OpenInputFile(temp_parquet_file_).ValueOrDie();
auto metadata = ::parquet::ReadMetaData(input_stream);
- std::vector<size_t> split_offsets;
+ const int64_t file_size = input_stream->GetSize().ValueOrDie();
+ std::vector<size_t> starts;
+ std::vector<size_t> midpoints;
for (int i = 0; i < metadata->num_row_groups(); ++i) {
-
split_offsets.push_back(static_cast<size_t>(metadata->RowGroup(i)->file_offset()));
+ auto column = metadata->RowGroup(i)->ColumnChunk(0);
+ ASSERT_TRUE(column->has_dictionary_page());
+ ASSERT_LT(column->dictionary_page_offset(), column->data_page_offset());
+ starts.push_back(static_cast<size_t>(column->dictionary_page_offset()));
+ ICEBERG_UNWRAP_OR_FAIL(auto midpoint,
+ RowGroupMidpoint(*metadata->RowGroup(i),
file_size));
+ ASSERT_GT(static_cast<size_t>(midpoint), starts.back());
+ midpoints.push_back(static_cast<size_t>(midpoint));
}
- ASSERT_EQ(split_offsets.size(), 2);
+ ASSERT_EQ(starts.size(), 2);
auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
SchemaField::MakeRequired(1, "id", int32()),
@@ -1026,13 +1119,26 @@ TEST_F(ParquetReaderTest, ReadSplit) {
std::vector<Split> splits = {
{.offset = 0, .length = std::numeric_limits<size_t>::max()},
- {.offset = split_offsets[0], .length = split_offsets[1] -
split_offsets[0]},
- {.offset = split_offsets[1], .length = 1},
- {.offset = split_offsets[1] + 1, .length =
std::numeric_limits<size_t>::max()},
- {.offset = 0, .length = split_offsets[0]},
+ {.offset = starts[0], .length = starts[1] - starts[0]},
+ {.offset = midpoints[1], .length = 1},
+ {.offset = midpoints[1] + 1, .length =
std::numeric_limits<size_t>::max()},
+ {.offset = 0, .length = midpoints[0]},
+ {.offset = midpoints[0] + 1, .length = midpoints[1] - midpoints[0]},
+ {.offset = starts[0], .length = 0},
+ // A row group belongs to the split containing its midpoint, not its
start.
+ {.offset = starts[1], .length = 1},
+ {.offset = starts[0] + 1, .length = midpoints[0] - starts[0]},
};
std::vector<std::string> expected_json = {
- R"([[1, 0], [2, 1], [3, 2]])", R"([[1, 0], [2, 1]])", R"([[3, 2]])", "",
"",
+ R"([[1, 0], [2, 1], [3, 2]])",
+ R"([[1, 0], [2, 1]])",
+ R"([[3, 2]])",
+ "",
+ "",
+ R"([[3, 2]])",
+ "",
+ "",
+ R"([[1, 0], [2, 1]])",
};
ReaderProperties reader_properties;
@@ -1056,6 +1162,222 @@ TEST_F(ParquetReaderTest, ReadSplit) {
}
}
+TEST_F(ParquetReaderTest, ReadSplitWithoutDictionaryPages) {
+ ASSERT_NO_FATAL_FAILURE(
+ CreateSplitParquetFile(/*row_group_size=*/2, /*dictionary=*/false));
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto metadata = ::parquet::ReadMetaData(input);
+ auto column = metadata->RowGroup(1)->ColumnChunk(0);
+ ASSERT_FALSE(column->has_dictionary_page());
+ const int64_t file_size = input->GetSize().ValueOrDie();
+ EXPECT_THAT(RowGroupStart(*metadata->RowGroup(1), file_size),
+ HasValue(::testing::Eq(column->data_page_offset())));
+ ICEBERG_UNWRAP_OR_FAIL(auto midpoint,
+ RowGroupMidpoint(*metadata->RowGroup(1), file_size));
+ auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
MetadataColumns::kRowPosition});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ ReaderFactoryRegistry::Open(
+ FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = static_cast<size_t>(midpoint), .length =
1},
+ .io = file_io_,
+ .projection = schema}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, R"([[3, 2]])"));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+}
+
+TEST_F(ParquetReaderTest, ReadSplitsWithOmittedRowGroupOffsets) {
+ auto arrow_schema = ::arrow::schema(
+ {::arrow::field("id", ::arrow::int32(), /*nullable=*/false,
+ ::arrow::KeyValueMetadata::Make({"PARQUET:field_id"},
{"1"}))});
+ auto table = ::arrow::Table::Make(
+ arrow_schema,
+ {::arrow::json::ArrayFromJSONString(::arrow::int32(), "[1, 2, 3, 4, 5,
6]")
+ .ValueOrDie()});
+ ::parquet::WriterProperties::Builder properties_builder;
+ properties_builder.version(::parquet::ParquetVersion::PARQUET_2_6)
+ ->compression(::parquet::Compression::UNCOMPRESSED);
+ auto properties = properties_builder.build();
+ ICEBERG_UNWRAP_OR_FAIL(auto original_output,
+ arrow::OpenArrowOutputStream(file_io_,
temp_parquet_file_));
+ ASSERT_TRUE(::parquet::arrow::WriteTable(*table,
::arrow::default_memory_pool(),
+ original_output, /*chunk_size=*/2,
properties)
+ .ok());
+ ASSERT_TRUE(original_output->Close().ok());
+ ICEBERG_UNWRAP_OR_FAIL(auto original_input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto original_metadata = ::parquet::ReadMetaData(original_input);
+ auto original_size = original_input->GetSize().ValueOrDie();
+ auto pages = original_input->ReadAt(0, original_size -
original_metadata->size() - 8)
+ .ValueOrDie();
+ ASSERT_TRUE(original_input->Close().ok());
+
+ auto builder =
+ ::parquet::FileMetaDataBuilder::Make(original_metadata->schema(),
properties);
+ for (int i = 0; i < original_metadata->num_row_groups(); ++i) {
+ auto original_row_group = original_metadata->RowGroup(i);
+ auto row_group = builder->AppendRowGroup();
+ row_group->set_num_rows(original_row_group->num_rows());
+ for (int j = 0; j < original_row_group->num_columns(); ++j) {
+ auto column = original_row_group->ColumnChunk(j);
+ row_group->NextColumnChunk()->Finish(
+ column->num_values(), column->dictionary_page_offset(),
+ /*index_page_offset=*/0, column->data_page_offset(),
+ column->total_compressed_size(), column->total_uncompressed_size(),
+ column->has_dictionary_page(), /*dictionary_fallback=*/false, {},
{});
+ }
+ // RowGroupMetaDataBuilder::Finish fills in the optional file_offset and
+ // total_compressed_size. Leave them unset to exercise legacy footer
handling.
+ }
+ auto rebuilt_metadata =
builder->Finish(original_metadata->key_value_metadata());
+ ICEBERG_UNWRAP_OR_FAIL(auto output,
+ arrow::OpenArrowOutputStream(file_io_,
temp_parquet_file_));
+ ASSERT_TRUE(output->Write(pages).ok());
+ ::parquet::WriteFileMetaData(*rebuilt_metadata, output.get());
+ ASSERT_TRUE(output->Close().ok());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto metadata = ::parquet::ReadMetaData(input);
+ ASSERT_EQ(metadata->num_columns(), 1);
+ ASSERT_EQ(metadata->num_rows(), 6);
+ ASSERT_EQ(metadata->num_row_groups(), 3);
+ for (int i = 0; i < metadata->num_row_groups(); ++i) {
+ auto row_group = metadata->RowGroup(i);
+ ASSERT_EQ(row_group->num_rows(), 2);
+ ASSERT_EQ(row_group->file_offset(), 0);
+ ASSERT_EQ(row_group->total_compressed_size(), 0);
+ auto column = row_group->ColumnChunk(0);
+ ASSERT_TRUE(column->has_dictionary_page());
+ ASSERT_LT(column->dictionary_page_offset(), column->data_page_offset());
+ }
+ auto boundary = static_cast<size_t>(
+ metadata->RowGroup(1)->ColumnChunk(0)->dictionary_page_offset());
+ auto file_size = static_cast<size_t>(input->GetSize().ValueOrDie());
+ auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
MetadataColumns::kRowPosition,
+ MetadataColumns::kRowId, MetadataColumns::kLastUpdatedSequenceNumber});
+ const std::vector<std::optional<Split>> splits{
+ std::nullopt, Split{.offset = 0, .length = boundary},
+ Split{.offset = boundary, .length = file_size - boundary}};
+ const std::vector<std::string> expected{
+ R"([[1, 0, 100, 7], [2, 1, 101, 7], [3, 2, 102, 7], [4, 3, 103, 7], [5,
4, 104, 7], [6, 5, 105, 7]])",
+ R"([[1, 0, 100, 7], [2, 1, 101, 7]])",
+ R"([[3, 2, 102, 7], [4, 3, 103, 7], [5, 4, 104, 7], [6, 5, 105, 7]])"};
+ for (size_t i = 0; i < splits.size(); ++i) {
+ SCOPED_TRACE(i);
+ ICEBERG_UNWRAP_OR_FAIL(auto reader,
+
ReaderFactoryRegistry::Open(FileFormatType::kParquet,
+ {.path =
temp_parquet_file_,
+ .length = file_size,
+ .split = splits[i],
+ .io = file_io_,
+ .projection = schema,
+ .first_row_id = 100,
+ .data_sequence_number
= 7}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, expected[i]));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+ }
+}
+
+TEST_F(ParquetReaderTest, RejectsSplitWithInvalidPageOffsetsBeforeReading) {
+ auto schema = std::make_shared<Schema>(
+ std::vector<SchemaField>{SchemaField::MakeRequired(1, "col0", int32())});
+ for (int64_t offset : {int64_t{-1}, int64_t{0},
std::numeric_limits<int64_t>::max()}) {
+ auto metadata = MakeMetadataWithPageOffsets({{offset, std::nullopt}});
+ ICEBERG_UNWRAP_OR_FAIL(auto output,
+ arrow::OpenArrowOutputStream(file_io_,
temp_parquet_file_));
+ ::parquet::WriteMetaDataFile(*metadata, output.get());
+ ASSERT_TRUE(output->Close().ok());
+ auto reader = ReaderFactoryRegistry::Open(FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = 0,
.length = 1},
+ .io = file_io_,
+ .projection = schema});
+ EXPECT_THAT(reader, IsError(ErrorKind::kInvalid));
+ EXPECT_THAT(reader, HasErrorMessage("row group 0"));
+ EXPECT_THAT(reader, HasErrorMessage(temp_parquet_file_));
+ }
+}
+
+TEST_F(ParquetReaderTest, ReadSplitWithNonmonotonicRowGroupStarts) {
+ ASSERT_NO_FATAL_FAILURE(CreateSplitParquetFile(/*row_group_size=*/1));
+ // Logical row order becomes [1, 3, 2], while the pages stay in physical
order [1, 2,
+ // 3].
+ ASSERT_NO_FATAL_FAILURE(ReorderRowGroups({0, 2, 1}));
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto metadata = ::parquet::ReadMetaData(input);
+ auto last_start = static_cast<size_t>(
+ metadata->RowGroup(1)->ColumnChunk(0)->dictionary_page_offset());
+ auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
MetadataColumns::kRowPosition,
+ MetadataColumns::kRowId, MetadataColumns::kLastUpdatedSequenceNumber});
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ ReaderFactoryRegistry::Open(FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = 0, .length =
last_start},
+ .io = file_io_,
+ .projection = schema,
+ .first_row_id = 100,
+ .data_sequence_number = 7}));
+ // A batch can span skipped row groups while keeping absolute positions and
lineage.
+ ASSERT_NO_FATAL_FAILURE(
+ VerifyNextBatch(*reader, R"([[1, 0, 100, 7], [2, 2, 102, 7]])"));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto tail, ReaderFactoryRegistry::Open(
+ FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = last_start,
+ .length =
std::numeric_limits<size_t>::max()},
+ .io = file_io_,
+ .projection = schema,
+ .first_row_id = 100,
+ .data_sequence_number = 7}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*tail, R"([[3, 1, 101, 7]])"));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*tail));
+}
+
+TEST_F(ParquetReaderTest, ReadMetadataOnlySplitWithNonmonotonicRowGroups) {
+ ASSERT_NO_FATAL_FAILURE(CreateSplitParquetFile(/*row_group_size=*/1));
+ ASSERT_NO_FATAL_FAILURE(ReorderRowGroups({0, 2, 1}));
+ ICEBERG_UNWRAP_OR_FAIL(auto input,
+ arrow::OpenArrowInputStream(file_io_,
temp_parquet_file_));
+ auto metadata = ::parquet::ReadMetaData(input);
+ auto last_start = static_cast<size_t>(
+ metadata->RowGroup(1)->ColumnChunk(0)->dictionary_page_offset());
+ auto schema = std::make_shared<Schema>(
+ std::vector<SchemaField>{MetadataColumns::kRowPosition,
MetadataColumns::kRowId,
+ MetadataColumns::kLastUpdatedSequenceNumber});
+
+ for (int64_t batch_size : {1, 100}) {
+ SCOPED_TRACE(batch_size);
+ ReaderProperties properties;
+ properties.Set(ReaderProperties::kBatchSize, batch_size);
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ ReaderFactoryRegistry::Open(FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = 0, .length =
last_start},
+ .io = file_io_,
+ .projection = schema,
+ .first_row_id = 100,
+ .data_sequence_number = 7,
+ .properties = properties}));
+ // Arrow emits one batch per row group when no physical columns are read.
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, R"([[0, 100, 7]])"));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, R"([[2, 102, 7]])"));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+ }
+}
+
TEST_F(ParquetReaderTest, ReadMetadataColumns) {
temp_parquet_file_ = "metadata_columns.parquet";
CreateSimpleParquetFile();