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 5a935034 feat(parquet): add row number reader for selected row groups
(#1006)
5a935034 is described below
commit 5a9350342b04a102f69aa8f626ca624072f69098
Author: advancedxy <[email protected]>
AuthorDate: Thu Oct 15 14:18:41 2026 +0800
feat(parquet): add row number reader for selected row groups (#1006)
---
src/iceberg/CMakeLists.txt | 3 +-
src/iceberg/arrow/metadata_column_util.cc | 18 +-
src/iceberg/arrow/metadata_column_util_internal.h | 26 +--
src/iceberg/parquet/parquet_data_util.cc | 91 +++++++---
src/iceberg/parquet/parquet_data_util_internal.h | 12 +-
src/iceberg/parquet/parquet_reader.cc | 35 +++-
src/iceberg/parquet/row_number_reader.cc | 123 +++++++++++++
src/iceberg/parquet/row_number_reader_internal.h | 80 +++++++++
src/iceberg/test/CMakeLists.txt | 3 +-
src/iceberg/test/arrow_test.cc | 11 ++
src/iceberg/test/parquet_data_test.cc | 129 ++++++++++++-
src/iceberg/test/parquet_test.cc | 88 +++++++++
src/iceberg/test/row_number_reader_test.cc | 209 ++++++++++++++++++++++
13 files changed, 755 insertions(+), 73 deletions(-)
diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt
index 6939b2be..717f1da4 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -322,7 +322,8 @@ if(ICEBERG_BUILD_BUNDLE)
parquet/parquet_reader.cc
parquet/parquet_register.cc
parquet/parquet_schema_util.cc
- parquet/parquet_writer.cc)
+ parquet/parquet_writer.cc
+ parquet/row_number_reader.cc)
# Libraries to link with exported libiceberg_bundle.{so,a}.
set(ICEBERG_BUNDLE_STATIC_BUILD_INTERFACE_LIBS)
diff --git a/src/iceberg/arrow/metadata_column_util.cc
b/src/iceberg/arrow/metadata_column_util.cc
index 4b81d7b1..0009bf37 100644
--- a/src/iceberg/arrow/metadata_column_util.cc
+++ b/src/iceberg/arrow/metadata_column_util.cc
@@ -69,21 +69,9 @@ Result<std::shared_ptr<::arrow::Array>>
MakeFilePathArray(const std::string& fil
return array;
}
-Result<std::shared_ptr<::arrow::Array>> MakeRowPositionArray(int64_t
start_position,
- int64_t num_rows,
-
::arrow::MemoryPool* pool) {
- ::arrow::Int64Builder builder(pool);
- ICEBERG_ARROW_RETURN_NOT_OK(builder.Reserve(num_rows));
- for (int64_t i = 0; i < num_rows; ++i) {
- ICEBERG_ARROW_RETURN_NOT_OK(builder.Append(start_position + i));
- }
- std::shared_ptr<::arrow::Array> array;
- ICEBERG_ARROW_RETURN_NOT_OK(builder.Finish(&array));
- return array;
-}
-
Result<std::shared_ptr<::arrow::Array>> MakeRowIdArray(
- std::optional<int64_t> first_row_id, int64_t start_position, int64_t
num_rows,
+ std::optional<int64_t> first_row_id,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions, int64_t
num_rows,
::arrow::MemoryPool* pool) {
::arrow::Int64Builder builder(pool);
ICEBERG_ARROW_RETURN_NOT_OK(builder.Reserve(num_rows));
@@ -92,7 +80,7 @@ Result<std::shared_ptr<::arrow::Array>> MakeRowIdArray(
} else {
for (int64_t row_index = 0; row_index < num_rows; ++row_index) {
ICEBERG_ARROW_RETURN_NOT_OK(
- builder.Append(first_row_id.value() + start_position + row_index));
+ builder.Append(first_row_id.value() +
row_positions->Value(row_index)));
}
}
diff --git a/src/iceberg/arrow/metadata_column_util_internal.h
b/src/iceberg/arrow/metadata_column_util_internal.h
index 580244e7..a08ea9bf 100644
--- a/src/iceberg/arrow/metadata_column_util_internal.h
+++ b/src/iceberg/arrow/metadata_column_util_internal.h
@@ -39,8 +39,9 @@ namespace iceberg::arrow {
/// \brief Context for populating metadata columns during reading.
struct MetadataColumnContext {
- std::string file_path; // The file path to populate _file column.
- int64_t next_file_pos = 0; // The next row position for populating _pos
column.
+ std::string file_path; // The file path to populate _file column.
+ // The file row position used when appending metadata values.
+ int64_t next_file_pos = 0;
std::optional<int64_t> first_row_id; // First row ID used to
populate _row_id.
std::optional<int64_t> data_sequence_number; // Data sequence number for
lineage.
};
@@ -66,27 +67,18 @@ Result<std::shared_ptr<::arrow::Array>>
MakeFilePathArray(const std::string& fil
int64_t num_rows,
::arrow::MemoryPool*
pool);
-/// \brief Create a sequential int64 array for _pos column.
-///
-/// Creates an Arrow Int64Array with sequential values starting from
start_position.
-///
-/// \param start_position Starting row position (inclusive).
-/// \param num_rows Number of rows in the batch.
-/// \param pool Arrow memory pool.
-/// \return Arrow Int64Array with values [start_position, start_position +
num_rows).
-Result<std::shared_ptr<::arrow::Array>> MakeRowPositionArray(int64_t
start_position,
- int64_t num_rows,
-
::arrow::MemoryPool* pool);
-
/// \brief Create a row ID array for _row_id column.
///
/// \param first_row_id First row ID of the data file.
-/// \param start_position Starting row position (inclusive).
+/// \param row_positions File row positions. May be null when first_row_id is
unknown;
+/// otherwise must contain num_rows non-null positions.
/// \param num_rows Number of rows in the batch.
/// \param pool Arrow memory pool.
-/// \return Arrow Int64Array for _row_id.
+/// \return Arrow Int64Array with `first_row_id + pos`, or all nulls when
+/// `first_row_id` is unknown.
Result<std::shared_ptr<::arrow::Array>> MakeRowIdArray(
- std::optional<int64_t> first_row_id, int64_t start_position, int64_t
num_rows,
+ std::optional<int64_t> first_row_id,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions, int64_t
num_rows,
::arrow::MemoryPool* pool);
/// \brief Create a sequence number array for _last_updated_sequence_number
column.
diff --git a/src/iceberg/parquet/parquet_data_util.cc
b/src/iceberg/parquet/parquet_data_util.cc
index 052d03b5..77524aac 100644
--- a/src/iceberg/parquet/parquet_data_util.cc
+++ b/src/iceberg/parquet/parquet_data_util.cc
@@ -17,6 +17,8 @@
* under the License.
*/
+#include <algorithm>
+
#include <arrow/array.h>
#include <arrow/array/builder_primitive.h>
#include <arrow/compute/api.h>
@@ -42,7 +44,8 @@ Result<std::shared_ptr<::arrow::Array>> ProjectNestedArray(
const std::shared_ptr<::arrow::Array>& array,
const std::shared_ptr<::arrow::DataType>& output_arrow_type,
const NestedType& nested_type, std::span<const FieldProjection>
projections,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool);
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
::arrow::MemoryPool* pool);
/// \brief Create a null array of the given type and length.
Result<std::shared_ptr<::arrow::Array>> MakeNullArray(
@@ -72,10 +75,12 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectPrimitiveArray(
Result<std::shared_ptr<::arrow::Array>> MakeInheritedRowLineageArray(
int32_t field_id, int64_t num_rows,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool) {
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
+ ::arrow::MemoryPool* pool) {
if (field_id == MetadataColumns::kRowIdColumnId) {
- return arrow::MakeRowIdArray(metadata_context.first_row_id,
- metadata_context.next_file_pos, num_rows,
pool);
+ return arrow::MakeRowIdArray(metadata_context.first_row_id, row_positions,
num_rows,
+ pool);
}
if (field_id == MetadataColumns::kLastUpdatedSequenceNumberColumnId) {
return arrow::MakeLastUpdatedSequenceNumberArray(
@@ -84,9 +89,14 @@ Result<std::shared_ptr<::arrow::Array>>
MakeInheritedRowLineageArray(
return NotSupported("Unsupported row lineage field id: {}", field_id);
}
+// Replace nulls in a physical row lineage column with inherited values:
+// `first_row_id + _pos` for _row_id and the data sequence number for
+// _last_updated_sequence_number.
Result<std::shared_ptr<::arrow::Array>> ProjectRowLineageArray(
const std::shared_ptr<::arrow::Array>& physical_array, int32_t field_id,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool) {
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
+ ::arrow::MemoryPool* pool) {
if (physical_array->null_count() == 0) {
return physical_array;
}
@@ -99,7 +109,12 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectRowLineageArray(
if (!values->IsNull(row_index)) {
ICEBERG_ARROW_RETURN_NOT_OK(builder.Append(values->Value(row_index)));
} else {
- row_metadata_context.next_file_pos = metadata_context.next_file_pos +
row_index;
+ // Only _row_id with a known first_row_id needs a file position. Use the
+ // original position because selected row groups may leave gaps.
+ if (field_id == MetadataColumns::kRowIdColumnId &&
+ metadata_context.first_row_id.has_value()) {
+ row_metadata_context.next_file_pos = row_positions->Value(row_index);
+ }
ICEBERG_RETURN_UNEXPECTED(arrow::AppendInheritedRowLineageValue(
field_id, row_metadata_context, &builder));
}
@@ -114,7 +129,9 @@ Result<std::shared_ptr<::arrow::Array>> ProjectStructArray(
const std::shared_ptr<::arrow::StructArray>& struct_array,
const std::shared_ptr<::arrow::StructType>& output_struct_type,
const StructType& struct_type, std::span<const FieldProjection>
projections,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool) {
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
+ ::arrow::MemoryPool* pool) {
if (struct_type.fields().size() != projections.size()) {
return InvalidSchema(
"Inconsistent number of fields ({}) and number of projections ({})",
@@ -147,15 +164,17 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectStructArray(
if (projected_field.type()->is_nested()) {
const auto& nested_type =
internal::checked_cast<const NestedType&>(*projected_field.type());
+ // Metadata columns belong to top-level rows; nested fields need no
positions.
ICEBERG_ASSIGN_OR_RAISE(
projected_array,
ProjectNestedArray(parquet_array, output_arrow_type, nested_type,
- field_projection.children, metadata_context,
pool));
+ field_projection.children, metadata_context,
+ /*row_positions=*/nullptr, pool));
} else if
(MetadataColumns::IsRowLineageColumn(projected_field.field_id())) {
ICEBERG_ASSIGN_OR_RAISE(
projected_array,
ProjectRowLineageArray(parquet_array, projected_field.field_id(),
- metadata_context, pool));
+ metadata_context, row_positions, pool));
} else {
ICEBERG_ASSIGN_OR_RAISE(
projected_array,
@@ -177,13 +196,12 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectStructArray(
arrow::MakeFilePathArray(metadata_context.file_path,
struct_array->length(), pool));
} else if (field_id == MetadataColumns::kFilePositionColumnId) {
- ICEBERG_ASSIGN_OR_RAISE(
- projected_array,
arrow::MakeRowPositionArray(metadata_context.next_file_pos,
-
struct_array->length(), pool));
+ projected_array = row_positions;
} else if (MetadataColumns::IsRowLineageColumn(field_id)) {
- ICEBERG_ASSIGN_OR_RAISE(projected_array, MakeInheritedRowLineageArray(
- field_id,
struct_array->length(),
- metadata_context, pool));
+ ICEBERG_ASSIGN_OR_RAISE(
+ projected_array,
+ MakeInheritedRowLineageArray(field_id, struct_array->length(),
+ metadata_context, row_positions,
pool));
} else {
return NotSupported("Unsupported metadata field id: {}", field_id);
}
@@ -237,10 +255,12 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectListArrayImpl(
} else if (element_field.type()->is_nested()) {
const auto& nested_type =
internal::checked_cast<const NestedType&>(*element_field.type());
+ // Metadata columns belong to top-level rows; list elements need no
positions.
ICEBERG_ASSIGN_OR_RAISE(
projected_values,
ProjectNestedArray(list_array->values(), output_element_type,
nested_type,
- element_projection.children, metadata_context,
pool));
+ element_projection.children, metadata_context,
+ /*row_positions=*/nullptr, pool));
} else {
ICEBERG_ASSIGN_OR_RAISE(
projected_values,
@@ -296,10 +316,12 @@ Result<std::shared_ptr<::arrow::Array>> ProjectMapArray(
ToString(key_projection.kind));
} else if (key_type->is_nested()) {
const auto& nested_type = internal::checked_cast<const
NestedType&>(*key_type);
+ // Metadata columns belong to top-level rows; map keys need no positions.
ICEBERG_ASSIGN_OR_RAISE(
projected_keys,
ProjectNestedArray(map_array->keys(), output_map_type->key_type(),
nested_type,
- key_projection.children, metadata_context, pool));
+ key_projection.children, metadata_context,
+ /*row_positions=*/nullptr, pool));
} else {
ICEBERG_ASSIGN_OR_RAISE(
projected_keys,
@@ -317,10 +339,12 @@ Result<std::shared_ptr<::arrow::Array>> ProjectMapArray(
ToString(value_projection.kind));
} else if (value_type->is_nested()) {
const auto& nested_type = internal::checked_cast<const
NestedType&>(*value_type);
+ // Metadata columns belong to top-level rows; map values need no positions.
ICEBERG_ASSIGN_OR_RAISE(
projected_items,
ProjectNestedArray(map_array->items(), output_map_type->item_type(),
nested_type,
- value_projection.children, metadata_context, pool));
+ value_projection.children, metadata_context,
+ /*row_positions=*/nullptr, pool));
} else {
ICEBERG_ASSIGN_OR_RAISE(
projected_items,
@@ -337,7 +361,9 @@ Result<std::shared_ptr<::arrow::Array>> ProjectNestedArray(
const std::shared_ptr<::arrow::Array>& array,
const std::shared_ptr<::arrow::DataType>& output_arrow_type,
const NestedType& nested_type, std::span<const FieldProjection>
projections,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool) {
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
+ ::arrow::MemoryPool* pool) {
switch (nested_type.type_id()) {
case TypeId::kStruct: {
if (output_arrow_type->id() != ::arrow::Type::STRUCT) {
@@ -349,7 +375,7 @@ Result<std::shared_ptr<::arrow::Array>> ProjectNestedArray(
internal::checked_pointer_cast<::arrow::StructType>(output_arrow_type);
const auto& struct_type = internal::checked_cast<const
StructType&>(nested_type);
return ProjectStructArray(struct_array, output_struct_type, struct_type,
- projections, metadata_context, pool);
+ projections, metadata_context, row_positions,
pool);
}
case TypeId::kList: {
const auto& list_type = internal::checked_cast<const
ListType&>(nested_type);
@@ -393,18 +419,39 @@ Result<std::shared_ptr<::arrow::Array>>
ProjectNestedArray(
} // namespace
+bool NeedsRowPositions(const Schema& read_schema,
+ const arrow::MetadataColumnContext& metadata_context) {
+ return std::ranges::any_of(read_schema.fields(), [&](const SchemaField&
field) {
+ return field.field_id() == MetadataColumns::kFilePositionColumnId ||
+ (field.field_id() == MetadataColumns::kRowIdColumnId &&
+ metadata_context.first_row_id.has_value());
+ });
+}
+
Result<std::shared_ptr<::arrow::RecordBatch>> ProjectRecordBatch(
std::shared_ptr<::arrow::RecordBatch> record_batch,
const std::shared_ptr<::arrow::Schema>& output_arrow_schema,
const Schema& projected_schema, const SchemaProjection& projection,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool) {
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
+ ::arrow::MemoryPool* pool) {
+ if (row_positions == nullptr && NeedsRowPositions(projected_schema,
metadata_context)) {
+ return InvalidArgument(
+ "Row positions are required to project _pos or inherit _row_id");
+ }
+ if (row_positions != nullptr && (row_positions->length() !=
record_batch->num_rows() ||
+ row_positions->null_count() != 0)) {
+ return InvalidArgument("Expected {} non-null row positions",
+ record_batch->num_rows());
+ }
auto array = std::make_shared<::arrow::StructArray>(
::arrow::struct_(record_batch->schema()->fields()),
record_batch->num_rows(),
record_batch->columns());
ICEBERG_ASSIGN_OR_RAISE(
auto output_array,
ProjectNestedArray(array,
::arrow::struct_(output_arrow_schema->fields()),
- projected_schema, projection.fields,
metadata_context, pool));
+ projected_schema, projection.fields, metadata_context,
+ row_positions, pool));
auto* struct_array =
internal::checked_cast<::arrow::StructArray*>(output_array.get());
return ::arrow::RecordBatch::Make(output_arrow_schema,
record_batch->num_rows(),
struct_array->fields());
diff --git a/src/iceberg/parquet/parquet_data_util_internal.h
b/src/iceberg/parquet/parquet_data_util_internal.h
index 5aad68f8..77bb5645 100644
--- a/src/iceberg/parquet/parquet_data_util_internal.h
+++ b/src/iceberg/parquet/parquet_data_util_internal.h
@@ -26,19 +26,27 @@
namespace iceberg::parquet {
+/// \brief Whether the schema projects _pos, or _row_id with a known
first_row_id.
+bool NeedsRowPositions(const Schema& read_schema,
+ const arrow::MetadataColumnContext& metadata_context);
+
/// \brief Convert record batch read from a Parquet file to projected Iceberg
Schema.
///
/// \param record_batch The record batch to convert.
/// \param output_arrow_schema The Arrow schema to convert to.
/// \param projected_schema The projected Iceberg schema.
/// \param projection The projection from projected Iceberg schema to the
record batch.
-/// \param metadata_context The metadata column context for populating _file
and _pos.
+/// \param metadata_context The file-level constants for populating metadata
columns.
+/// \param row_positions Optional file row positions for _pos and inherited
_row_id.
+/// Required when NeedsRowPositions(projected_schema, metadata_context) is
true.
+/// When non-null, must contain one non-null value per row in record_batch.
/// \param pool The arrow memory pool.
/// \return The converted record batch.
Result<std::shared_ptr<::arrow::RecordBatch>> ProjectRecordBatch(
std::shared_ptr<::arrow::RecordBatch> record_batch,
const std::shared_ptr<::arrow::Schema>& output_arrow_schema,
const Schema& projected_schema, const SchemaProjection& projection,
- const arrow::MetadataColumnContext& metadata_context, ::arrow::MemoryPool*
pool);
+ const arrow::MetadataColumnContext& metadata_context,
+ const std::shared_ptr<::arrow::Int64Array>& row_positions,
::arrow::MemoryPool* pool);
} // namespace iceberg::parquet
diff --git a/src/iceberg/parquet/parquet_reader.cc
b/src/iceberg/parquet/parquet_reader.cc
index 1f814610..3f15ab7a 100644
--- a/src/iceberg/parquet/parquet_reader.cc
+++ b/src/iceberg/parquet/parquet_reader.cc
@@ -24,6 +24,7 @@
#include <variant>
#include <vector>
+#include <arrow/array.h>
#include <arrow/c/bridge.h>
#include <arrow/memory_pool.h>
#include <arrow/record_batch.h>
@@ -41,6 +42,7 @@
#include "iceberg/parquet/parquet_data_util_internal.h"
#include "iceberg/parquet/parquet_register.h"
#include "iceberg/parquet/parquet_schema_util_internal.h"
+#include "iceberg/parquet/row_number_reader_internal.h"
#include "iceberg/result.h"
#include "iceberg/schema_internal.h"
#include "iceberg/schema_util.h"
@@ -268,6 +270,10 @@ struct ReadContext {
std::shared_ptr<::arrow::Schema> output_arrow_schema_;
// The reader to read record batches from the Parquet file.
std::unique_ptr<::arrow::RecordBatchReader> record_batch_reader_;
+ // Produces file row positions in lockstep with `record_batch_reader_`. Only
set when
+ // the projection needs row positions (_pos or inherited _row_id).
+ // TODO(advancedxy): use Arrow C++'s row number virtual column once
available.
+ std::unique_ptr<RowNumberReader> row_number_reader_;
};
// TODO(gangwu): list of work items
@@ -308,7 +314,6 @@ class ParquetReader::Impl {
// Project read schema onto the Parquet file schema
ICEBERG_ASSIGN_OR_RAISE(projection_, BuildProjection(reader_.get(),
*read_schema_));
metadata_context_ = {.file_path = options.path,
- .next_file_pos = 0,
.first_row_id = options.first_row_id,
.data_sequence_number = options.data_sequence_number};
@@ -326,11 +331,20 @@ class ParquetReader::Impl {
return std::nullopt;
}
+ std::shared_ptr<::arrow::Int64Array> row_positions;
+ if (context_->row_number_reader_) {
+ ICEBERG_ASSIGN_OR_RAISE(row_positions,
+
context_->row_number_reader_->NextBatch(batch->num_rows()));
+ if (row_positions->length() != batch->num_rows()) {
+ return Invalid("Parquet batch has {} rows, but row number reader
returned {}",
+ batch->num_rows(), row_positions->length());
+ }
+ }
+
ICEBERG_ASSIGN_OR_RAISE(
batch, ProjectRecordBatch(std::move(batch),
context_->output_arrow_schema_,
- *read_schema_, projection_,
metadata_context_, pool_));
-
- metadata_context_.next_file_pos += batch->num_rows();
+ *read_schema_, projection_,
metadata_context_,
+ row_positions, pool_));
ArrowArray arrow_array;
ICEBERG_ARROW_RETURN_NOT_OK(::arrow::ExportRecordBatch(*batch,
&arrow_array));
@@ -392,9 +406,9 @@ class ParquetReader::Impl {
// Row group pruning based on the split
// 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()) {
- auto metadata = reader_->parquet_reader()->metadata();
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 &&
@@ -402,12 +416,10 @@ class ParquetReader::Impl {
row_group_indices.push_back(i);
} else if (row_group_offset >= split_->offset + split_->length) {
break;
- } else {
- metadata_context_.next_file_pos += metadata->RowGroup(i)->num_rows();
}
}
} else {
-
row_group_indices.resize(reader_->parquet_reader()->metadata()->num_row_groups());
+ row_group_indices.resize(metadata->num_row_groups());
std::iota(row_group_indices.begin(), row_group_indices.end(), 0); //
NOLINT
}
@@ -422,6 +434,11 @@ class ParquetReader::Impl {
reader_->GetRecordBatchReader(row_group_indices, column_indices));
}
+ if (NeedsRowPositions(*read_schema_, metadata_context_)) {
+ 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
// target of ProjectRecordBatch, so it must describe the projected schema
rather than
// the schema of the file.
@@ -460,7 +477,7 @@ class ParquetReader::Impl {
std::shared_ptr<::arrow::io::RandomAccessFile> input_stream_;
// Parquet file reader to create RecordBatchReader.
std::unique_ptr<::parquet::arrow::FileReader> reader_;
- // Metadata column context for populating _file and _pos columns.
+ // File-level constants for metadata and row lineage columns.
arrow::MetadataColumnContext metadata_context_;
// The context to keep track of the reading progress.
std::unique_ptr<ReadContext> context_;
diff --git a/src/iceberg/parquet/row_number_reader.cc
b/src/iceberg/parquet/row_number_reader.cc
new file mode 100644
index 00000000..3ba5a424
--- /dev/null
+++ b/src/iceberg/parquet/row_number_reader.cc
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <limits>
+
+#include <arrow/array.h>
+#include <arrow/array/builder_primitive.h>
+#include <arrow/memory_pool.h>
+#include <parquet/metadata.h>
+
+#include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/parquet/row_number_reader_internal.h"
+
+namespace iceberg::parquet {
+
+Result<std::unique_ptr<RowNumberReader>> RowNumberReader::Make(
+ const ::parquet::FileMetaData& metadata, const std::vector<int>&
row_groups,
+ ::arrow::MemoryPool* pool) {
+ // Prefix sums in footer order, including the end of the last row group.
+ const int num_row_groups = metadata.num_row_groups();
+ std::vector<int64_t> offsets(static_cast<size_t>(num_row_groups) + 1, 0);
+ for (int i = 0; i < num_row_groups; ++i) {
+ const int64_t num_rows = metadata.RowGroup(i)->num_rows();
+ if (num_rows < 0 || num_rows > std::numeric_limits<int64_t>::max() -
offsets[i]) {
+ return InvalidArgument("Invalid row count for row group {}: {}", i,
num_rows);
+ }
+ offsets[i + 1] = offsets[i] + num_rows;
+ }
+
+ // Keep the requested order, including repeated row groups.
+ std::deque<RowRange> ranges;
+ for (int row_group : row_groups) {
+ if (row_group < 0 || row_group >= num_row_groups) {
+ return InvalidArgument("Row group index {} out of bounds [0, {})",
row_group,
+ num_row_groups);
+ }
+ if (offsets[row_group] != offsets[row_group + 1]) {
+ ranges.push_back({.start = offsets[row_group], .end = offsets[row_group
+ 1]});
+ }
+ }
+
+ return std::unique_ptr<RowNumberReader>(new
RowNumberReader(std::move(ranges), pool));
+}
+
+Result<std::shared_ptr<::arrow::Int64Array>> RowNumberReader::NextBatch(
+ int64_t batch_size) {
+ if (batch_size < 0) {
+ return InvalidArgument("Negative batch size: {}", batch_size);
+ }
+
+ std::vector<RowRange> batch_ranges;
+ int64_t remaining = batch_size;
+ while (remaining > 0) {
+ auto range = TakeRange(remaining);
+ if (!range) {
+ break;
+ }
+ remaining -= range->end - range->start;
+ batch_ranges.push_back(*range);
+ }
+
+ auto result = BuildArray(batch_ranges, batch_size - remaining);
+ if (!result.has_value()) {
+ // Restore the consumed ranges in their original order.
+ ranges_.insert(ranges_.begin(), batch_ranges.begin(), batch_ranges.end());
+ }
+ return result;
+}
+
+std::optional<RowNumberReader::RowRange> RowNumberReader::TakeRange(int64_t
count) {
+ if (ranges_.empty()) {
+ return std::nullopt;
+ }
+
+ auto range = ranges_.front();
+ if (range.end - range.start <= count) {
+ ranges_.pop_front();
+ } else {
+ range.end = range.start + count;
+ ranges_.front().start = range.end;
+ }
+ return range;
+}
+
+Result<std::shared_ptr<::arrow::Int64Array>> RowNumberReader::BuildArray(
+ const std::vector<RowRange>& ranges, int64_t length) {
+ // Arrow's numeric builder converts the element count to bytes internally.
+ // Check the actual batch length, not the requested upper bound.
+ if (length >
+ std::numeric_limits<int64_t>::max() /
static_cast<int64_t>(sizeof(int64_t))) {
+ return InvalidArgument("Row number batch is too large");
+ }
+
+ ::arrow::Int64Builder builder(pool_);
+ ICEBERG_ARROW_RETURN_NOT_OK(builder.Reserve(length));
+ for (const auto& range : ranges) {
+ for (int64_t row = range.start; row < range.end; ++row) {
+ builder.UnsafeAppend(row);
+ }
+ }
+ std::shared_ptr<::arrow::Int64Array> array;
+ ICEBERG_ARROW_RETURN_NOT_OK(builder.Finish(&array));
+
+ return array;
+}
+
+} // namespace iceberg::parquet
diff --git a/src/iceberg/parquet/row_number_reader_internal.h
b/src/iceberg/parquet/row_number_reader_internal.h
new file mode 100644
index 00000000..49068d57
--- /dev/null
+++ b/src/iceberg/parquet/row_number_reader_internal.h
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+/// \file iceberg/parquet/row_number_reader_internal.h
+/// Produces file row positions for the rows emitted by a Parquet record batch
reader.
+
+#include <cstdint>
+#include <deque>
+#include <memory>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include <arrow/type_fwd.h>
+
+#include "iceberg/result.h"
+
+namespace parquet {
+class FileMetaData;
+} // namespace parquet
+
+namespace iceberg::parquet {
+
+/// \brief Reader of file row positions ("row numbers") for selected row
groups.
+///
+/// Row numbers are absolute positions in the file: row groups are numbered by
prefix
+/// sums of `num_rows` in footer order, and selected row groups are emitted in
the
+/// requested order, which may skip or reorder row groups.
+///
+/// Like parquet-rs RowNumberReader, this keeps ranges until a batch is
requested.
+/// Row positions are independent of Iceberg metadata column and lineage
semantics.
+class RowNumberReader {
+ public:
+ /// \brief Create a reader for `row_groups`, in the order they will be read.
+ static Result<std::unique_ptr<RowNumberReader>> Make(
+ const ::parquet::FileMetaData& metadata, const std::vector<int>&
row_groups,
+ ::arrow::MemoryPool* pool);
+
+ /// \brief Read up to `batch_size` row numbers, returning an empty array at
EOF.
+ /// If the call fails, no row numbers are consumed and the call can be
retried.
+ Result<std::shared_ptr<::arrow::Int64Array>> NextBatch(int64_t batch_size);
+
+ private:
+ /// \brief Half-open range [start, end) of file row positions.
+ struct RowRange {
+ int64_t start;
+ int64_t end;
+ };
+
+ RowNumberReader(std::deque<RowRange> ranges, ::arrow::MemoryPool* pool)
+ : ranges_(std::move(ranges)), pool_(pool) {}
+
+ // Take up to count rows from the first range, splitting it when necessary.
+ std::optional<RowRange> TakeRange(int64_t count);
+ Result<std::shared_ptr<::arrow::Int64Array>> BuildArray(
+ const std::vector<RowRange>& ranges, int64_t length);
+
+ std::deque<RowRange> ranges_;
+ ::arrow::MemoryPool* pool_;
+};
+
+} // namespace iceberg::parquet
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index ac978c2c..292132bb 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -220,7 +220,8 @@ if(ICEBERG_BUILD_BUNDLE)
parquet_data_test.cc
parquet_metrics_test.cc
parquet_schema_test.cc
- parquet_test.cc)
+ parquet_test.cc
+ row_number_reader_test.cc)
if(ICEBERG_ENCRYPTION)
target_sources(parquet_test PRIVATE parquet_encryption_test.cc)
endif()
diff --git a/src/iceberg/test/arrow_test.cc b/src/iceberg/test/arrow_test.cc
index feecabf1..c6681480 100644
--- a/src/iceberg/test/arrow_test.cc
+++ b/src/iceberg/test/arrow_test.cc
@@ -20,6 +20,7 @@
#include <functional>
#include <memory>
#include <mutex>
+#include <optional>
#include <string>
#include <string_view>
#include <thread>
@@ -38,6 +39,7 @@
#include <gtest/gtest.h>
#include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/arrow/metadata_column_util_internal.h"
#include "iceberg/constants.h"
#include "iceberg/result.h"
#include "iceberg/schema.h"
@@ -48,6 +50,15 @@
namespace iceberg {
+TEST(MetadataColumnUtilTest, RowIdsWithoutFirstRowId) {
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto row_ids,
+ arrow::MakeRowIdArray(std::nullopt, nullptr, 3,
::arrow::default_memory_pool()));
+ ASSERT_TRUE(row_ids->ValidateFull().ok());
+ EXPECT_EQ(row_ids->length(), 3);
+ EXPECT_EQ(row_ids->null_count(), row_ids->length());
+}
+
struct ToArrowSchemaParam {
std::shared_ptr<Type> iceberg_type;
bool optional = true;
diff --git a/src/iceberg/test/parquet_data_test.cc
b/src/iceberg/test/parquet_data_test.cc
index e300d69b..f7072324 100644
--- a/src/iceberg/test/parquet_data_test.cc
+++ b/src/iceberg/test/parquet_data_test.cc
@@ -29,6 +29,7 @@
#include <gtest/gtest.h>
#include "iceberg/expression/literal.h"
+#include "iceberg/metadata_columns.h"
#include "iceberg/parquet/parquet_data_util_internal.h"
#include "iceberg/schema.h"
#include "iceberg/schema_internal.h"
@@ -55,9 +56,11 @@ std::shared_ptr<::arrow::RecordBatch> RecordBatchFromJson(
struct_array->fields());
}
-void VerifyProjectRecordBatch(const Schema& projected_schema, const Schema&
source_schema,
- const std::string& input_json,
- const std::string& expected_json) {
+void VerifyProjectRecordBatch(
+ const Schema& projected_schema, const Schema& source_schema,
+ const std::string& input_json, const std::string& expected_json,
+ const arrow::MetadataColumnContext& metadata_context = {},
+ const std::shared_ptr<::arrow::Int64Array>& row_positions = nullptr) {
auto schema_projection_result =
Project(projected_schema, source_schema, /*prune_source=*/false);
ASSERT_THAT(schema_projection_result, IsOk());
@@ -75,7 +78,7 @@ void VerifyProjectRecordBatch(const Schema& projected_schema,
const Schema& sour
auto project_result = ProjectRecordBatch(
input_record_batch, projected_arrow_schema, projected_schema,
schema_projection,
- /*metadata_context=*/{}, ::arrow::default_memory_pool());
+ metadata_context, row_positions, ::arrow::default_memory_pool());
ASSERT_THAT(project_result, IsOk());
auto projected_record_batch = std::move(project_result.value());
@@ -88,6 +91,120 @@ void VerifyProjectRecordBatch(const Schema&
projected_schema, const Schema& sour
class ProjectRecordBatchTest : public
::testing::TestWithParam<ProjectRecordBatchParam> {
};
+TEST(ProjectRecordBatchTest, InheritedLineageUsesFilePositions) {
+ Schema source_schema({SchemaField::MakeRequired(1, "id", int32())});
+ Schema projected_schema({SchemaField::MakeRequired(1, "id", int32()),
+ MetadataColumns::kRowPosition,
MetadataColumns::kRowId,
+ MetadataColumns::kLastUpdatedSequenceNumber});
+ auto positions = std::static_pointer_cast<::arrow::Int64Array>(
+ ::arrow::json::ArrayFromJSONString(::arrow::int64(), "[12, 3,
30]").ValueOrDie());
+ ASSERT_NO_FATAL_FAILURE(VerifyProjectRecordBatch(
+ projected_schema, source_schema, "[[10], [20], [30]]",
+ "[[10, 12, 112, 7], [20, 3, 103, 7], [30, 30, 130, 7]]",
+ {.first_row_id = 100, .data_sequence_number = 7}, positions));
+}
+
+TEST(ProjectRecordBatchTest, PhysicalLineageUsesFilePositionsForNulls) {
+ Schema source_schema({SchemaField::MakeRequired(1, "id", int32()),
+ MetadataColumns::kRowId,
+ MetadataColumns::kLastUpdatedSequenceNumber});
+ Schema projected_schema({SchemaField::MakeRequired(1, "id", int32()),
+ MetadataColumns::kRowPosition,
MetadataColumns::kRowId,
+ MetadataColumns::kLastUpdatedSequenceNumber});
+ // A sliced array verifies that lineage uses the array's logical offset.
+ auto positions = std::static_pointer_cast<::arrow::Int64Array>(
+ ::arrow::json::ArrayFromJSONString(::arrow::int64(), "[999, 12, 3, 30]")
+ .ValueOrDie()
+ ->Slice(1));
+ ASSERT_NO_FATAL_FAILURE(VerifyProjectRecordBatch(
+ projected_schema, source_schema, "[[10, null, null], [20, 777, 8], [30,
null, 9]]",
+ "[[10, 12, 112, 7], [20, 3, 777, 8], [30, 30, 130, 9]]",
+ {.first_row_id = 100, .data_sequence_number = 7}, positions));
+}
+
+TEST(ProjectRecordBatchTest, RowPositionsMustMatchBatch) {
+ Schema source_schema(std::vector<SchemaField>{});
+ auto input = ::arrow::RecordBatch::Make(::arrow::schema({}), 3,
::arrow::ArrayVector{});
+ for (const auto& field : {MetadataColumns::kRowPosition,
MetadataColumns::kRowId}) {
+ SCOPED_TRACE(field.name());
+ Schema projected_schema({field});
+ ICEBERG_UNWRAP_OR_FAIL(auto projection, Project(projected_schema,
source_schema,
+ /*prune_source=*/false));
+ auto output_schema = ::arrow::schema(
+ {::arrow::field(std::string(field.name()), ::arrow::int64(),
field.optional())});
+
+ EXPECT_THAT(ProjectRecordBatch(input, output_schema, projected_schema,
projection,
+ {.first_row_id = 100}, nullptr,
+ ::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+ for (const auto& json : {"[12, 3]", "[12, null, 30]"}) {
+ auto positions = std::static_pointer_cast<::arrow::Int64Array>(
+ ::arrow::json::ArrayFromJSONString(::arrow::int64(),
json).ValueOrDie());
+ EXPECT_THAT(ProjectRecordBatch(input, output_schema, projected_schema,
projection,
+ {.first_row_id = 100}, positions,
+ ::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+ }
+ }
+}
+
+TEST(ProjectRecordBatchTest, RowPositionsRequiredForPhysicalRowIdInheritance) {
+ Schema schema({MetadataColumns::kRowId});
+ ICEBERG_UNWRAP_OR_FAIL(auto projection,
+ Project(schema, schema, /*prune_source=*/false));
+ auto arrow_schema = ::arrow::schema({::arrow::field("_row_id",
::arrow::int64())});
+ auto input = RecordBatchFromJson(arrow_schema, "[[null], [777], [null]]");
+ EXPECT_THAT(
+ ProjectRecordBatch(input, arrow_schema, schema, projection,
{.first_row_id = 100},
+ nullptr, ::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST(ProjectRecordBatchTest, LineageWithoutRowPositions) {
+ Schema data_schema({SchemaField::MakeRequired(1, "id", int32())});
+ Schema lineage_schema({SchemaField::MakeRequired(1, "id", int32()),
+ MetadataColumns::kRowId,
+ MetadataColumns::kLastUpdatedSequenceNumber});
+ ASSERT_NO_FATAL_FAILURE(VerifyProjectRecordBatch(
+ lineage_schema, data_schema, "[[10], [20], [30]]",
+ "[[10, null, 7], [20, null, 7], [30, null, 7]]", {.data_sequence_number
= 7}));
+ const std::string physical_values = "[[10, null, null], [20, 777, 8], [30,
null, 9]]";
+ ASSERT_NO_FATAL_FAILURE(VerifyProjectRecordBatch(
+ lineage_schema, lineage_schema, physical_values,
+ "[[10, null, 7], [20, 777, 8], [30, null, 9]]", {.data_sequence_number =
7}));
+ ASSERT_NO_FATAL_FAILURE(VerifyProjectRecordBatch(lineage_schema,
lineage_schema,
+ physical_values,
physical_values));
+}
+
+TEST(ProjectRecordBatchTest, RowPositionsAreIndependentOfPhysicalColumns) {
+ Schema source_schema({SchemaField::MakeRequired(1, "_pos", int64())});
+ Schema projected_schema({MetadataColumns::kRowPosition,
MetadataColumns::kRowId});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto projection, Project(projected_schema, source_schema,
/*prune_source=*/false));
+ auto positions = std::static_pointer_cast<::arrow::Int64Array>(
+ ::arrow::json::ArrayFromJSONString(::arrow::int64(), "[12, 3,
30]").ValueOrDie());
+ auto physical =
+ ::arrow::json::ArrayFromJSONString(::arrow::int64(), "[10, 20,
30]").ValueOrDie();
+ auto output_schema = ::arrow::schema({::arrow::field("_pos",
::arrow::int64(), false),
+ ::arrow::field("_row_id",
::arrow::int64())});
+ // A physical column named _pos must not be mistaken for the virtual column.
+ auto input_schema = ::arrow::schema({::arrow::field("_pos",
::arrow::int64())});
+ auto input = ::arrow::RecordBatch::Make(input_schema, 3, {physical});
+ EXPECT_THAT(
+ ProjectRecordBatch(input, output_schema, projected_schema, projection,
+ {.first_row_id = 100}, nullptr,
::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto output, ProjectRecordBatch(input, output_schema, projected_schema,
projection,
+ {.first_row_id = 100}, positions,
+ ::arrow::default_memory_pool()));
+ EXPECT_TRUE(output->column(0)->Equals(*positions));
+ EXPECT_EQ(output->column(0)->data()->buffers[1],
positions->data()->buffers[1]);
+ EXPECT_TRUE(output->Equals(
+ *RecordBatchFromJson(output_schema, "[[12, 112], [3, 103], [30,
130]]")));
+}
+
TEST_P(ProjectRecordBatchTest, PrimitiveType) {
const auto& test_case = GetParam();
@@ -659,7 +776,7 @@ TEST(ProjectRecordBatchTest, LargeListOfIntegers) {
// Project the record batch
auto project_result = ProjectRecordBatch(
input_record_batch, output_arrow_schema, iceberg_schema,
schema_projection,
- /*metadata_context=*/{}, ::arrow::default_memory_pool());
+ /*metadata_context=*/{}, /*row_positions=*/nullptr,
::arrow::default_memory_pool());
ASSERT_THAT(project_result, IsOk());
auto projected_record_batch = std::move(project_result.value());
@@ -751,7 +868,7 @@ TEST(ProjectRecordBatchTest, LargeListOfStructs) {
// Project the record batch
auto project_result = ProjectRecordBatch(
input_record_batch, output_arrow_schema, iceberg_schema,
schema_projection,
- /*metadata_context=*/{}, ::arrow::default_memory_pool());
+ /*metadata_context=*/{}, /*row_positions=*/nullptr,
::arrow::default_memory_pool());
ASSERT_THAT(project_result, IsOk());
auto projected_record_batch = std::move(project_result.value());
diff --git a/src/iceberg/test/parquet_test.cc b/src/iceberg/test/parquet_test.cc
index ce43962c..6dedba44 100644
--- a/src/iceberg/test/parquet_test.cc
+++ b/src/iceberg/test/parquet_test.cc
@@ -18,6 +18,8 @@
*/
#include <array>
+#include <format>
+#include <limits>
#include <optional>
#include <string>
#include <utility>
@@ -1029,6 +1031,92 @@ TEST_F(ParquetReaderTest, ReadPhysicalLineage) {
[3, 102, null]])"));
}
+TEST_F(ParquetReaderTest, ReadPositionsAndLineageAcrossRowGroups) {
+ temp_parquet_file_ = "positions_across_row_groups.parquet";
+ constexpr int64_t kRowGroupRows = 100;
+ constexpr int64_t kNumRows = 3 * kRowGroupRows;
+ constexpr int64_t kFirstRowId = 1000;
+
+ // Odd rows keep a physical _row_id; even rows inherit `first_row_id + _pos`.
+ std::string input_json = "[";
+ for (int64_t i = 0; i < kNumRows; ++i) {
+ input_json += std::format("{}[{}, {}, null]", i == 0 ? "" : ",", i,
+ i % 2 == 0 ? "null" : std::to_string(5000 + i));
+ }
+ input_json += "]";
+
+ auto write_schema = RowLineageSchema();
+ ArrowSchema arrow_c_schema;
+ ASSERT_THAT(ToArrowSchema(*write_schema, &arrow_c_schema), IsOk());
+ auto arrow_type = ::arrow::ImportType(&arrow_c_schema).ValueOrDie();
+ auto array = ::arrow::json::ArrayFromJSONString(arrow_type,
input_json).ValueOrDie();
+
+ WriterProperties writer_properties;
+ writer_properties.Set(WriterProperties::kParquetCompression,
+ std::string("uncompressed"));
+ writer_properties.Set(WriterProperties::kParquetMaxRowGroupRows,
kRowGroupRows);
+ ASSERT_THAT(WriteArray(array, {.path = temp_parquet_file_,
+ .schema = write_schema,
+ .io = file_io_,
+ .properties = std::move(writer_properties)}),
+ IsOk());
+
+ auto io = internal::checked_cast<arrow::ArrowFileSystemFileIO&>(*file_io_);
+ auto metadata =
+
::parquet::ReadMetaData(io.fs()->OpenInputFile(temp_parquet_file_).ValueOrDie());
+ ASSERT_EQ(metadata->num_row_groups(), 3);
+
+ auto read_schema = std::make_shared<Schema>(std::vector<SchemaField>{
+ SchemaField::MakeRequired(1, "id", int32()),
+ MetadataColumns::kRowPosition,
+ MetadataColumns::kRowId,
+ });
+
+ auto expected_json = [&](int64_t start, int64_t end) {
+ std::string json = "[";
+ for (int64_t pos = start; pos < end; ++pos) {
+ json += std::format("{}[{}, {}, {}]", pos == start ? "" : ",", pos, pos,
+ pos % 2 == 0 ? kFirstRowId + pos : 5000 + pos);
+ }
+ return json + "]";
+ };
+
+ ReaderProperties reader_properties;
+ reader_properties.Set(ReaderProperties::kBatchSize, int64_t{150});
+
+ // Skip the first row group; the first batch spans the second and third ones.
+ const auto second_offset =
static_cast<size_t>(metadata->RowGroup(1)->file_offset());
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ ReaderFactoryRegistry::Open(
+ FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = second_offset,
+ .length = std::numeric_limits<size_t>::max() -
second_offset},
+ .io = file_io_,
+ .projection = read_schema,
+ .first_row_id = kFirstRowId,
+ .properties = reader_properties}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, expected_json(100, 250)));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, expected_json(250, 300)));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+
+ // Read only the middle row group.
+ const auto third_offset =
static_cast<size_t>(metadata->RowGroup(2)->file_offset());
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto middle_reader,
+ ReaderFactoryRegistry::Open(FileFormatType::kParquet,
+ {.path = temp_parquet_file_,
+ .split = Split{.offset = second_offset,
+ .length = third_offset -
second_offset},
+ .io = file_io_,
+ .projection = read_schema,
+ .first_row_id = kFirstRowId,
+ .properties = reader_properties}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*middle_reader, expected_json(100,
200)));
+ ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*middle_reader));
+}
+
TEST_F(ParquetReaderTest, ReadNestedUnknownProjection) {
temp_parquet_file_ = "nested_unknown.parquet";
auto write_schema = std::make_shared<Schema>(std::vector<SchemaField>{
diff --git a/src/iceberg/test/row_number_reader_test.cc
b/src/iceberg/test/row_number_reader_test.cc
new file mode 100644
index 00000000..65d521a9
--- /dev/null
+++ b/src/iceberg/test/row_number_reader_test.cc
@@ -0,0 +1,209 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <limits>
+#include <memory>
+#include <numeric>
+#include <vector>
+
+#include <arrow/array.h>
+#include <arrow/buffer.h>
+#include <arrow/memory_pool.h>
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+#include <parquet/metadata.h>
+#include <parquet/properties.h>
+#include <parquet/schema.h>
+
+#include "iceberg/parquet/row_number_reader_internal.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::parquet {
+
+namespace {
+
+std::unique_ptr<::parquet::FileMetaData> MakeMetadata(
+ const std::vector<int64_t>& row_group_sizes) {
+ // Only row counts are needed; no column data or file I/O is involved.
+ ::parquet::SchemaDescriptor schema;
+ schema.Init(
+ ::parquet::schema::GroupNode::Make("schema",
::parquet::Repetition::REQUIRED, {}));
+ auto builder = ::parquet::FileMetaDataBuilder::Make(
+ &schema, ::parquet::default_writer_properties());
+ for (int64_t size : row_group_sizes) {
+ auto* row_group = builder->AppendRowGroup();
+ row_group->set_num_rows(size);
+ row_group->Finish(0);
+ }
+ return builder->Finish();
+}
+
+void VerifyNextBatch(RowNumberReader& reader, int64_t batch_size,
+ const std::vector<int64_t>& expected) {
+ ICEBERG_UNWRAP_OR_FAIL(auto array, reader.NextBatch(batch_size));
+ ASSERT_TRUE(array->ValidateFull().ok());
+ ASSERT_EQ(array->null_count(), 0);
+ ASSERT_EQ(array->length(), expected.size());
+ for (size_t i = 0; i < expected.size(); ++i) {
+ EXPECT_EQ(array->Value(i), expected[i]) << "Row " << i;
+ }
+}
+
+} // namespace
+
+TEST(RowNumberReaderTest, ReadsAllRowGroups) {
+ auto metadata = MakeMetadata({2, 3, 4});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {0, 1, 2},
::arrow::default_memory_pool()));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 100, {0, 1, 2, 3, 4, 5, 6,
7, 8}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {}));
+}
+
+TEST(RowNumberReaderTest, SkippedRowGroupKeepsFilePositions) {
+ auto metadata = MakeMetadata({3, 3, 3});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {0, 2},
::arrow::default_memory_pool()));
+
+ // The first batch spans the gap left by the skipped row group.
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 5, {0, 1, 2, 6, 7}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 5, {8}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {}));
+}
+
+TEST(RowNumberReaderTest, ReorderedAndRepeatedRowGroups) {
+ auto metadata = MakeMetadata({2, 2, 2});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {2, 0, 2},
::arrow::default_memory_pool()));
+
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 3, {4, 5, 0}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 3, {1, 4, 5}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {}));
+}
+
+TEST(RowNumberReaderTest, BatchesNotAlignedWithRowGroups) {
+ auto metadata = MakeMetadata({3, 3, 3});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {0, 1, 2},
::arrow::default_memory_pool()));
+
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {0, 1}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 4, {2, 3, 4, 5}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 4, {6, 7, 8}));
+}
+
+TEST(RowNumberReaderTest, EmptyRowGroups) {
+ auto metadata = MakeMetadata({0, 2, 0, 1, 0});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {0, 1, 2, 3, 4},
::arrow::default_memory_pool()));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {0, 1}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {2}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {}));
+}
+
+TEST(RowNumberReaderTest, NoRowGroups) {
+ for (const auto& sizes : {std::vector<int64_t>{}, std::vector<int64_t>{5}}) {
+ auto metadata = MakeMetadata(sizes);
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader,
+ RowNumberReader::Make(*metadata, {}, ::arrow::default_memory_pool()));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 10, {}));
+ }
+}
+
+TEST(RowNumberReaderTest, ZeroAndNegativeBatchSizesDoNotAdvance) {
+ auto metadata = MakeMetadata({2});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader, RowNumberReader::Make(*metadata, {0},
::arrow::default_memory_pool()));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 0, {}));
+ EXPECT_THAT(reader->NextBatch(-1), IsError(ErrorKind::kInvalidArgument));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {0}));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 0, {}));
+ ASSERT_NO_FATAL_FAILURE(
+ VerifyNextBatch(*reader, std::numeric_limits<int64_t>::max(), {1}));
+}
+
+TEST(RowNumberReaderTest, RowGroupIndexOutOfBounds) {
+ auto metadata = MakeMetadata({5});
+ for (int index : {-1, 1}) {
+ EXPECT_THAT(RowNumberReader::Make(*metadata, {index},
::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+ }
+}
+
+TEST(RowNumberReaderTest, NegativeRowCount) {
+ auto metadata = MakeMetadata({-1});
+ EXPECT_THAT(RowNumberReader::Make(*metadata, {0},
::arrow::default_memory_pool()),
+ IsError(ErrorKind::kInvalidArgument));
+}
+
+TEST(RowNumberReaderTest, OversizedBatchDoesNotAdvance) {
+ auto metadata = MakeMetadata({std::numeric_limits<int64_t>::max()});
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto reader, RowNumberReader::Make(*metadata, {0},
::arrow::default_memory_pool()));
+ EXPECT_THAT(reader->NextBatch(std::numeric_limits<int64_t>::max()),
+ IsError(ErrorKind::kInvalidArgument));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {0, 1}));
+}
+
+TEST(RowNumberReaderTest, AllocationFailureDoesNotAdvance) {
+ auto metadata = MakeMetadata({64, 64, 64, 64});
+ ::arrow::ProxyMemoryPool tracked_pool(::arrow::default_memory_pool());
+ ::arrow::CappedMemoryPool pool(&tracked_pool, 2048);
+ ICEBERG_UNWRAP_OR_FAIL(auto reader, RowNumberReader::Make(*metadata, {0, 2,
3}, &pool));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {0}));
+
+ // Leave room for the values buffer, but not the validity bitmap.
+ auto held_buffer = ::arrow::AllocateBuffer(1024, &pool).ValueOrDie();
+ EXPECT_FALSE(reader->NextBatch(128).has_value());
+ EXPECT_EQ(pool.bytes_allocated(), 1024);
+ EXPECT_FALSE(reader->NextBatch(128).has_value());
+ EXPECT_EQ(pool.bytes_allocated(), 1024);
+
+ // Retry the same request after freeing memory, including the row group gap.
+ held_buffer.reset();
+ EXPECT_EQ(pool.bytes_allocated(), 0);
+ std::vector<int64_t> expected(128);
+ std::iota(expected.begin(), expected.begin() + 63, 1);
+ std::iota(expected.begin() + 63, expected.end(), 128);
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 128, expected));
+ // The last row group was only partially consumed by the successful retry.
+ expected.resize(63);
+ std::ranges::iota(expected, 193);
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 128, expected));
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 1, {}));
+}
+
+TEST(RowNumberReaderTest, BatchesOwnBuffersFromMemoryPool) {
+ auto metadata = MakeMetadata({4});
+ ::arrow::ProxyMemoryPool pool(::arrow::default_memory_pool());
+ ICEBERG_UNWRAP_OR_FAIL(auto reader, RowNumberReader::Make(*metadata, {0},
&pool));
+ ICEBERG_UNWRAP_OR_FAIL(auto first_batch, reader->NextBatch(2));
+ EXPECT_GT(pool.bytes_allocated(), 0);
+ ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader, 2, {2, 3}));
+ reader.reset();
+ EXPECT_EQ(first_batch->Value(0), 0);
+ EXPECT_EQ(first_batch->Value(1), 1);
+}
+
+} // namespace iceberg::parquet