Skip to content

Commit 7f8009f

Browse files
feat(scan): read Parquet files by task range (#1002)
1 parent 90c9340 commit 7f8009f

10 files changed

Lines changed: 713 additions & 70 deletions

‎src/iceberg/data/file_scan_task_reader.cc‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,21 @@ namespace iceberg {
3939

4040
namespace {
4141

42-
ReaderOptions MakeReaderOptions(const DataFile& data_file, std::shared_ptr<FileIO> io,
42+
ReaderOptions MakeReaderOptions(const FileScanTask& task, std::shared_ptr<FileIO> io,
4343
std::shared_ptr<Schema> projection,
4444
std::shared_ptr<Expression> filter,
4545
std::shared_ptr<NameMapping> name_mapping,
4646
ReaderProperties properties,
4747
std::optional<int64_t> first_row_id,
4848
std::optional<int64_t> data_sequence_number) {
49+
const auto& data_file = *task.data_file();
4950
return ReaderOptions{
5051
.path = data_file.file_path,
5152
.length = static_cast<size_t>(data_file.file_size_in_bytes),
53+
.split = task.is_split() ? std::make_optional(
54+
Split{.offset = static_cast<size_t>(task.start()),
55+
.length = static_cast<size_t>(task.length())})
56+
: std::nullopt,
5257
.io = std::move(io),
5358
.projection = std::move(projection),
5459
.filter = std::move(filter),
@@ -163,16 +168,29 @@ class FileScanTaskReader::Impl {
163168
ICEBERG_PRECHECK(data_file->file_size_in_bytes >= 0,
164169
"Data file size must not be negative: {}",
165170
data_file->file_size_in_bytes);
171+
ICEBERG_PRECHECK(std::in_range<size_t>(data_file->file_size_in_bytes),
172+
"Data file size cannot be represented as size_t: {}",
173+
data_file->file_size_in_bytes);
166174
if (task.is_split()) {
167-
return NotSupported(
168-
"Reading a partial FileScanTask is not supported yet: {} "
169-
"(start={}, length={})",
170-
data_file->file_path, task.start(), task.length());
175+
ICEBERG_PRECHECK(task.start() >= 0 &&
176+
task.start() <= data_file->file_size_in_bytes &&
177+
task.length() > 0 &&
178+
task.length() <= data_file->file_size_in_bytes - task.start(),
179+
"Task range is outside file {} of size {} (start={}, length={})",
180+
data_file->file_path, data_file->file_size_in_bytes, task.start(),
181+
task.length());
182+
// The validated endpoints are bounded by the size_t-representable file size.
183+
if (data_file->file_format != FileFormatType::kParquet) {
184+
return NotSupported(
185+
"Reading a partial FileScanTask requires Parquet: {} "
186+
"(start={}, length={})",
187+
data_file->file_path, task.start(), task.length());
188+
}
171189
}
172190

173191
if (task.delete_files().empty()) {
174192
auto options = MakeReaderOptions(
175-
*data_file, io_, projected_schema_, task.residual_filter(), name_mapping_,
193+
task, io_, projected_schema_, task.residual_filter(), name_mapping_,
176194
properties_, data_file->first_row_id, data_file->data_sequence_number);
177195
ICEBERG_ASSIGN_OR_RAISE(
178196
auto reader, ReaderFactoryRegistry::Open(data_file->file_format, options));
@@ -197,9 +215,9 @@ class FileScanTaskReader::Impl {
197215
ProjectionContext::Make(*required_schema, *projected_schema_,
198216
project_batch_function));
199217

200-
auto options = MakeReaderOptions(
201-
*data_file, io_, required_schema, task.residual_filter(), name_mapping_,
202-
properties_, data_file->first_row_id, data_file->data_sequence_number);
218+
auto options = MakeReaderOptions(task, io_, required_schema, task.residual_filter(),
219+
name_mapping_, properties_, data_file->first_row_id,
220+
data_file->data_sequence_number);
203221
ICEBERG_ASSIGN_OR_RAISE(auto reader,
204222
ReaderFactoryRegistry::Open(data_file->file_format, options));
205223

‎src/iceberg/data/file_scan_task_reader.h‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,11 @@ class ICEBERG_DATA_EXPORT FileScanTaskReader {
6868

6969
/// \brief Open a task and return an Arrow C stream for its projected live rows.
7070
///
71-
/// Partial tasks are rejected until range-aware reading is implemented. The task and
72-
/// its shared data/delete-file metadata must not be mutated while the stream uses them.
71+
/// Partial tasks are supported for Parquet, with each row group assigned to the
72+
/// half-open range containing its midpoint, as in Java Iceberg. Row positions and
73+
/// inherited row IDs remain relative to the whole file. Other formats require
74+
/// whole-file tasks. The task and its shared data/delete-file metadata must not be
75+
/// mutated while the stream uses them.
7376
Result<ArrowArrayStream> Open(const FileScanTask& task);
7477

7578
FileScanTaskReader(const FileScanTaskReader&) = delete;

‎src/iceberg/parquet/parquet_data_util.cc‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include <arrow/compute/api.h>
2525
#include <arrow/record_batch.h>
2626
#include <arrow/type.h>
27+
#include <parquet/metadata.h>
2728

2829
#include "iceberg/arrow/arrow_status_internal.h"
2930
#include "iceberg/arrow/literal_util_internal.h"
@@ -37,6 +38,61 @@
3738

3839
namespace iceberg::parquet {
3940

41+
Result<int64_t> RowGroupStart(const ::parquet::RowGroupMetaData& row_group,
42+
int64_t file_size) {
43+
if (row_group.num_columns() == 0) {
44+
return Invalid("Cannot split a Parquet row group without columns");
45+
}
46+
auto column = row_group.ColumnChunk(0);
47+
int64_t start = column->data_page_offset();
48+
// A dictionary page comes before the data pages. Like parquet-java, ignore it
49+
// unless its offset is positive and earlier.
50+
if (column->has_dictionary_page()) {
51+
const int64_t dictionary_start = column->dictionary_page_offset();
52+
if (dictionary_start > 0 && dictionary_start < start) {
53+
start = dictionary_start;
54+
}
55+
}
56+
// Byte 0 is the "PAR1" magic, so no page can start there.
57+
if (start <= 0 || start >= file_size) {
58+
return Invalid(
59+
"Cannot split a Parquet row group with missing or out-of-file "
60+
"starting page {} (file size {})",
61+
start, file_size);
62+
}
63+
return start;
64+
}
65+
66+
Result<int64_t> RowGroupMidpoint(const ::parquet::RowGroupMetaData& row_group,
67+
int64_t file_size) {
68+
ICEBERG_ASSIGN_OR_RAISE(auto start, RowGroupStart(row_group, file_size));
69+
// The row group's pages are contiguous from its start, so its size is bounded by the
70+
// rest of the file. This bound also keeps the sum below from overflowing.
71+
const int64_t max_size = file_size - start;
72+
// Arrow reports an omitted row-group size as 0. Like parquet-java, fall back to the
73+
// sum of the column chunk sizes.
74+
int64_t size = row_group.total_compressed_size();
75+
if (size == 0) {
76+
for (int i = 0; i < row_group.num_columns(); ++i) {
77+
const int64_t column_size = row_group.ColumnChunk(i)->total_compressed_size();
78+
if (column_size < 0 || column_size > max_size - size) {
79+
return Invalid(
80+
"Cannot split a Parquet row group with invalid column {} compressed size {} "
81+
"(start {}, file size {})",
82+
i, column_size, start, file_size);
83+
}
84+
size += column_size;
85+
}
86+
}
87+
if (size < 0 || size > max_size) {
88+
return Invalid(
89+
"Cannot split a Parquet row group with invalid compressed size {} "
90+
"(start {}, file size {})",
91+
size, start, file_size);
92+
}
93+
return start + size / 2;
94+
}
95+
4096
namespace {
4197

4298
// Forward declaration

‎src/iceberg/parquet/parquet_data_util_internal.h‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,27 @@
2424
#include "iceberg/arrow/metadata_column_util_internal.h"
2525
#include "iceberg/schema_util.h"
2626

27+
namespace parquet {
28+
class RowGroupMetaData;
29+
} // namespace parquet
30+
2731
namespace iceberg::parquet {
2832

2933
/// \brief Whether direct fields need positions for _pos or inherited _row_id.
3034
bool NeedsRowPositions(const StructType& struct_type,
3135
const arrow::MetadataColumnContext& metadata_context);
3236

37+
/// \brief Return the first column's starting page, matching parquet-java's
38+
/// BlockMetaData.getStartingPos(). Reject missing or out-of-file starts.
39+
Result<int64_t> RowGroupStart(const ::parquet::RowGroupMetaData& row_group,
40+
int64_t file_size);
41+
42+
/// \brief Return the byte offset that assigns a row group to a split: its start plus
43+
/// half its compressed size, matching parquet-java's range filter used by Java Iceberg.
44+
/// Reject sizes that are negative or extend past the end of the file.
45+
Result<int64_t> RowGroupMidpoint(const ::parquet::RowGroupMetaData& row_group,
46+
int64_t file_size);
47+
3348
/// \brief Convert record batch read from a Parquet file to projected Iceberg Schema.
3449
///
3550
/// \param record_batch The record batch to convert.

‎src/iceberg/parquet/parquet_reader.cc‎

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
#include "iceberg/parquet/parquet_reader.h"
2121

2222
#include <algorithm>
23+
#include <limits>
2324
#include <numeric>
25+
#include <utility>
2426
#include <variant>
2527
#include <vector>
2628

@@ -276,7 +278,6 @@ class ParquetReader::Impl {
276278
return InvalidArgument("Projected schema is required by Parquet reader");
277279
}
278280

279-
split_ = options.split;
280281
read_schema_ = options.projection;
281282

282283
// Prepare reader properties
@@ -297,6 +298,7 @@ class ParquetReader::Impl {
297298
ICEBERG_ARROW_ASSIGN_OR_RETURN(
298299
reader_, ::parquet::arrow::FileReader::Make(pool_, std::move(file_reader),
299300
arrow_reader_properties));
301+
ICEBERG_RETURN_UNEXPECTED(PlanRowGroups(options));
300302

301303
// Project read schema onto the Parquet file schema
302304
ICEBERG_ASSIGN_OR_RAISE(projection_, BuildProjection(reader_.get(), *read_schema_));
@@ -388,45 +390,70 @@ class ParquetReader::Impl {
388390
}
389391

390392
private:
391-
Status InitReadContext() {
392-
context_ = std::make_unique<ReadContext>();
393-
394-
// Row group pruning based on the split
393+
Status PlanRowGroups(const ReaderOptions& options) {
395394
// TODO(gangwu): add row group filtering based on zone map, bloom filter, etc.
396395
auto metadata = reader_->parquet_reader()->metadata();
397-
std::vector<int> row_group_indices;
398-
if (split_.has_value()) {
396+
if (options.split.has_value()) {
397+
const auto& split = *options.split;
398+
ICEBERG_ARROW_ASSIGN_OR_RETURN(auto file_size, input_stream_->GetSize());
399+
int64_t row_count = 0;
399400
for (int i = 0; i < metadata->num_row_groups(); ++i) {
400-
auto row_group_offset = metadata->RowGroup(i)->file_offset();
401-
if (row_group_offset >= split_->offset &&
402-
row_group_offset < split_->offset + split_->length) {
403-
row_group_indices.push_back(i);
404-
} else if (row_group_offset >= split_->offset + split_->length) {
405-
break;
401+
auto row_group = metadata->RowGroup(i);
402+
// Empty row groups have no pages to assign to a split.
403+
if (row_group->num_rows() == 0) {
404+
continue;
405+
}
406+
// Like Java Iceberg, a split reads the row groups whose midpoints it contains.
407+
auto midpoint = RowGroupMidpoint(*row_group, file_size);
408+
if (!midpoint.has_value()) {
409+
return Invalid(
410+
"Cannot read Parquet split {} (start={}, length={}), row "
411+
"group {}: {}",
412+
options.path, split.offset, split.length, i, midpoint.error().message);
413+
}
414+
// Subtraction avoids overflow for a caller-supplied unbounded split length.
415+
if (std::cmp_greater_equal(*midpoint, split.offset) &&
416+
static_cast<uint64_t>(*midpoint) - split.offset < split.length) {
417+
row_group_indices_.push_back(i);
418+
}
419+
if (row_group->num_rows() < 0 ||
420+
row_group->num_rows() > std::numeric_limits<int64_t>::max() - row_count) {
421+
return Invalid("Invalid Parquet row count in file {}, row group {}",
422+
options.path, i);
406423
}
424+
row_count += row_group->num_rows();
407425
}
408-
} else {
409-
row_group_indices.resize(metadata->num_row_groups());
410-
std::iota(row_group_indices.begin(), row_group_indices.end(), 0); // NOLINT
426+
} else if (metadata->num_row_groups() > 0) {
427+
row_group_indices_.resize(metadata->num_row_groups());
428+
std::iota(row_group_indices_.begin(), row_group_indices_.end(), 0); // NOLINT
411429
}
430+
return {};
431+
}
412432

433+
Status InitReadContext() {
434+
// Build locally so initialization can be retried without consuming any rows.
435+
auto context = std::make_unique<ReadContext>();
413436
// Create the record batch reader, including for empty splits to preserve the schema.
414-
auto column_indices = SelectedColumnIndices(projection_);
415437
ICEBERG_ARROW_ASSIGN_OR_RETURN(
416-
context_->record_batch_reader_,
417-
reader_->GetRecordBatchReader(row_group_indices, column_indices));
438+
context->record_batch_reader_,
439+
reader_->GetRecordBatchReader(row_group_indices_,
440+
SelectedColumnIndices(projection_)));
418441

419442
if (NeedsRowPositions(*read_schema_, metadata_context_)) {
420-
ICEBERG_ASSIGN_OR_RAISE(context_->row_number_reader_,
421-
RowNumberReader::Make(*metadata, row_group_indices, pool_));
443+
auto metadata = reader_->parquet_reader()->metadata();
444+
// Share the batch reader's selection so positions remain file-relative even
445+
// when a batch spans nonconsecutive row groups.
446+
ICEBERG_ASSIGN_OR_RAISE(
447+
context->row_number_reader_,
448+
RowNumberReader::Make(*metadata, row_group_indices_, pool_));
422449
}
423450

424451
// Build the output Arrow schema from the projected Iceberg schema. This schema is the
425452
// target of ProjectRecordBatch, so it must describe the projected schema rather than
426453
// the schema of the file.
427454
ArrowSchema arrow_schema;
428455
ICEBERG_RETURN_UNEXPECTED(ToArrowSchema(*read_schema_, &arrow_schema));
429-
ICEBERG_ARROW_ASSIGN_OR_RETURN(context_->output_arrow_schema_,
456+
ICEBERG_ARROW_ASSIGN_OR_RETURN(context->output_arrow_schema_,
430457
::arrow::ImportSchema(&arrow_schema));
431458

432459
// Align the output schema with the arrays the reader actually produces. The reader's
@@ -437,18 +464,18 @@ class ParquetReader::Impl {
437464
// 3. Mixed list and large_list types in files with stored schemas
438465
// For each projected field, we use the reader's actual type. For missing fields
439466
// (columns not in the file), we apply the configured use_large_list preference.
440-
context_->output_arrow_schema_ = AlignOutputSchemaToReaderSchema(
441-
context_->output_arrow_schema_, context_->record_batch_reader_->schema(),
467+
context->output_arrow_schema_ = AlignOutputSchemaToReaderSchema(
468+
context->output_arrow_schema_, context->record_batch_reader_->schema(),
442469
projection_, use_large_list_);
443-
470+
context_ = std::move(context);
444471
return {};
445472
}
446473

447474
private:
448475
// TODO(gangwu): make memory pool configurable
449476
::arrow::MemoryPool* pool_ = ::arrow::default_memory_pool();
450-
// The split to read from the Parquet file.
451-
std::optional<Split> split_;
477+
// Row groups in footer order, shared by the batch and row-number readers.
478+
std::vector<int> row_group_indices_;
452479
// Whether to read list columns as large_list (64-bit offsets).
453480
bool use_large_list_ = false;
454481
// Schema to read from the Parquet file.

‎src/iceberg/table_scan.cc‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ Result<std::shared_ptr<FileScanTask>> FileScanTask::MakeSplit(
224224

225225
return std::shared_ptr<FileScanTask>(
226226
new FileScanTask(std::move(data_file), std::move(delete_files), std::move(filter),
227-
Range{.start = start, .length = length, .file_size = file_size}));
227+
Range{.start = start, .length = length}));
228228
}
229229

230230
int64_t FileScanTask::length() const {
@@ -241,16 +241,20 @@ int64_t FileScanTask::estimated_row_count() const {
241241
}
242242

243243
const int64_t record_count = data_file_->record_count;
244-
if (record_count <= 0) {
244+
const int64_t file_size = data_file_->file_size_in_bytes;
245+
if (record_count <= 0 || file_size <= 0 || range_->start > file_size ||
246+
range_->length > file_size - range_->start) {
247+
// Keep arithmetic safe even for tasks invalidated by metadata mutation.
245248
return 0;
246249
}
247250

248-
// floor(end * rows / size) - floor(start * rows / size), so splits that cover
249-
// the file sum exactly to record_count. int128 avoids overflow.
251+
// floor(end * rows / size) - floor(start * rows / size). For non-overlapping
252+
// splits covering a file with unchanged metadata, estimates sum to record_count.
253+
// int128 avoids overflow.
250254
const auto end = static_cast<int128_t>(range_->start) + range_->length;
251-
const auto count_at_end = end * record_count / range_->file_size;
255+
const auto count_at_end = end * record_count / file_size;
252256
const auto count_at_start =
253-
static_cast<int128_t>(range_->start) * record_count / range_->file_size;
257+
static_cast<int128_t>(range_->start) * record_count / file_size;
254258
return static_cast<int64_t>(count_at_end - count_at_start);
255259
}
256260

0 commit comments

Comments
 (0)