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 26ae9eab feat(scan): Support split and parallel scans - Part-1:
validated FileScanTask byte ranges (#976)
26ae9eab is described below
commit 26ae9eab89d809a0861dfcef61ed6dc10f2b30bb
Author: kamcheungting-db <[email protected]>
AuthorDate: Wed Oct 14 20:22:33 2026 -0700
feat(scan): Support split and parallel scans - Part-1: validated
FileScanTask byte ranges (#976)
Preserve start and length in core and REST JSON, and reject partial
reads until the reader can honor ranges.
A shared DataFile's size may change after MakeSplit, so ToJson could
emit a range that FileScanTaskFromJson rejects. Revalidate the range
before serializing, matching the REST serializer.
Co-authored-by: Isaac <[email protected]>
---
src/iceberg/catalog/rest/json_serde.cc | 42 ++++--
src/iceberg/catalog/rest/types.cc | 1 +
src/iceberg/data/file_scan_task_reader.cc | 6 +
src/iceberg/data/file_scan_task_reader.h | 3 +
src/iceberg/json_serde.cc | 34 ++---
src/iceberg/json_serde_internal.h | 13 +-
src/iceberg/table_scan.cc | 73 +++++++++-
src/iceberg/table_scan.h | 36 +++++
src/iceberg/test/file_scan_task_reader_test.cc | 19 +++
src/iceberg/test/file_scan_task_test.cc | 115 +++++++++++++++
src/iceberg/test/json_serde_test.cc | 105 ++++++++++++--
src/iceberg/test/rest_json_serde_test.cc | 193 +++++++++++++++++++++++--
12 files changed, 575 insertions(+), 65 deletions(-)
diff --git a/src/iceberg/catalog/rest/json_serde.cc
b/src/iceberg/catalog/rest/json_serde.cc
index a54042c2..7e90bf35 100644
--- a/src/iceberg/catalog/rest/json_serde.cc
+++ b/src/iceberg/catalog/rest/json_serde.cc
@@ -115,6 +115,8 @@ constexpr std::string_view kDeleteFileReferences =
"delete-file-references";
constexpr std::string_view kResidualFilter = "residual-filter";
constexpr std::string_view kStart = "start";
constexpr std::string_view kLength = "length";
+constexpr std::string_view kMapKeys = "keys";
+constexpr std::string_view kMapValues = "values";
Result<nlohmann::json> StorageCredentialToJson(const StorageCredential&
credential) {
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -187,14 +189,19 @@ Result<std::vector<std::shared_ptr<FileScanTask>>>
FileScanTasksFromJson(
ICEBERG_ASSIGN_OR_RAISE(auto data_file_json,
GetJsonValue<nlohmann::json>(task_json,
kDataFile));
ICEBERG_ASSIGN_OR_RAISE(
- auto data_file,
- iceberg::DataFileFromJson(data_file_json, partition_spec_by_id,
schema));
- ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonIntegerOrDefault(task_json,
kStart, 0));
- ICEBERG_ASSIGN_OR_RAISE(
- auto length,
- GetJsonIntegerOrDefault(task_json, kLength,
data_file.file_size_in_bytes));
- ICEBERG_RETURN_UNEXPECTED(
- CheckFileScanTaskNotSplit(start, length,
data_file.file_size_in_bytes));
+ auto data_file, DataFileFromJson(data_file_json, partition_spec_by_id,
schema));
+ // Neither field means the whole file. Only one field is ambiguous.
+ const bool has_start = task_json.contains(kStart);
+ const bool has_length = task_json.contains(kLength);
+ if (has_start != has_length) {
+ return JsonParseError("'start' and 'length' must be provided together");
+ }
+ int64_t start = 0;
+ int64_t length = data_file.file_size_in_bytes;
+ if (has_start) {
+ ICEBERG_ASSIGN_OR_RAISE(start, GetJsonInteger<int64_t>(task_json,
kStart));
+ ICEBERG_ASSIGN_OR_RAISE(length, GetJsonInteger<int64_t>(task_json,
kLength));
+ }
// FIXME: REST scan-task DataFile JSON currently carries first-row-id,
// but not the manifest-entry data sequence number. Until the REST API
exposes
// it, REST-planned tasks cannot inherit _last_updated_sequence_number.
@@ -222,9 +229,13 @@ Result<std::vector<std::shared_ptr<FileScanTask>>>
FileScanTasksFromJson(
ICEBERG_ASSIGN_OR_RAISE(residual_filter,
ExpressionFromJson(filter_json));
}
- file_scan_tasks.push_back(std::make_shared<FileScanTask>(
- std::make_shared<DataFile>(std::move(data_file)),
std::move(task_delete_files),
- std::move(residual_filter)));
+ auto task =
FileScanTask::MakeSplit(std::make_shared<DataFile>(std::move(data_file)),
+ start, length,
std::move(task_delete_files),
+ std::move(residual_filter));
+ if (!task) {
+ return JsonParseError("Invalid FileScanTask range: {}",
task.error().message);
+ }
+ file_scan_tasks.push_back(std::move(task.value()));
}
return file_scan_tasks;
}
@@ -272,10 +283,19 @@ Result<nlohmann::json> ScanTaskFieldsToJson(
if (!task) continue;
nlohmann::json task_json;
if (task->data_file()) {
+ // The shared data file may have changed size; don't write an invalid
range.
+ auto range_result =
+ FileScanTask::MakeSplit(task->data_file(), task->start(),
task->length());
+ if (!range_result) {
+ return ValidationFailed("Invalid FileScanTask range: {}",
+ range_result.error().message);
+ }
ICEBERG_ASSIGN_OR_RAISE(
auto data_file_json,
iceberg::ToJson(*task->data_file(), partition_specs_by_id,
schema));
task_json[kDataFile] = std::move(data_file_json);
+ task_json[kStart] = task->start();
+ task_json[kLength] = task->length();
}
if (!task->delete_files().empty()) {
std::vector<int32_t> refs;
diff --git a/src/iceberg/catalog/rest/types.cc
b/src/iceberg/catalog/rest/types.cc
index 156df17e..00053743 100644
--- a/src/iceberg/catalog/rest/types.cc
+++ b/src/iceberg/catalog/rest/types.cc
@@ -192,6 +192,7 @@ bool FileScanTaskEqual(const std::shared_ptr<FileScanTask>&
lhs,
return false;
}
return SharedPtrEqual(lhs->data_file(), rhs->data_file()) &&
+ lhs->start() == rhs->start() && lhs->length() == rhs->length() &&
SharedPtrVectorEqual(lhs->delete_files(), rhs->delete_files()) &&
ExpressionPtrEqual(lhs->residual_filter(), rhs->residual_filter());
}
diff --git a/src/iceberg/data/file_scan_task_reader.cc
b/src/iceberg/data/file_scan_task_reader.cc
index 0c41f25e..7d3a8bc7 100644
--- a/src/iceberg/data/file_scan_task_reader.cc
+++ b/src/iceberg/data/file_scan_task_reader.cc
@@ -163,6 +163,12 @@ 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);
+ if (task.is_split()) {
+ return NotSupported(
+ "Reading a partial FileScanTask is not supported yet: {} "
+ "(start={}, length={})",
+ data_file->file_path, task.start(), task.length());
+ }
if (task.delete_files().empty()) {
auto options = MakeReaderOptions(
diff --git a/src/iceberg/data/file_scan_task_reader.h
b/src/iceberg/data/file_scan_task_reader.h
index a71ef5f8..c34f3e7a 100644
--- a/src/iceberg/data/file_scan_task_reader.h
+++ b/src/iceberg/data/file_scan_task_reader.h
@@ -67,6 +67,9 @@ class ICEBERG_DATA_EXPORT FileScanTaskReader {
~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.
Result<ArrowArrayStream> Open(const FileScanTask& task);
FileScanTaskReader(const FileScanTaskReader&) = delete;
diff --git a/src/iceberg/json_serde.cc b/src/iceberg/json_serde.cc
index c1c3ffb7..c604a2e0 100644
--- a/src/iceberg/json_serde.cc
+++ b/src/iceberg/json_serde.cc
@@ -2408,6 +2408,13 @@ Result<nlohmann::json> ToJson(
if (task.data_file()->content != DataFile::Content::kData) {
return ValidationFailed("FileScanTask data-file must have data content");
}
+ // The shared data file may have changed size; don't write an invalid range.
+ auto range_result =
+ FileScanTask::MakeSplit(task.data_file(), task.start(), task.length());
+ if (!range_result) {
+ return ValidationFailed("Invalid FileScanTask range: {}",
+ range_result.error().message);
+ }
nlohmann::json json;
json[kTaskType] = kFileScanTaskType;
@@ -2423,8 +2430,8 @@ Result<nlohmann::json> ToJson(
}
json[kSpec] = ToJson(*spec_it->second);
json[kDataFile] = std::move(data_file_json);
- json[kStart] = 0;
- json[kLength] = task.data_file()->file_size_in_bytes;
+ json[kStart] = task.start();
+ json[kLength] = task.length();
nlohmann::json delete_files_json = nlohmann::json::array();
for (const auto& delete_file : task.delete_files()) {
@@ -2457,17 +2464,6 @@ Result<nlohmann::json> ToJson(
return json;
}
-Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
- int64_t file_size_in_bytes) {
- if (start != 0 || length != file_size_in_bytes) {
- return NotSupported(
- "Split FileScanTask is not supported: start={}, length={}, "
- "file-size-in-bytes={}",
- start, length, file_size_in_bytes);
- }
- return {};
-}
-
Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(const
nlohmann::json& json) {
if (!json.is_object()) {
return JsonParseError("Cannot parse file scan task from a non-object: {}",
@@ -2510,8 +2506,6 @@ Result<std::shared_ptr<FileScanTask>>
FileScanTaskFromJson(const nlohmann::json&
ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonInteger<int64_t>(json, kStart));
ICEBERG_ASSIGN_OR_RAISE(auto length, GetJsonInteger<int64_t>(json, kLength));
- ICEBERG_RETURN_UNEXPECTED(
- CheckFileScanTaskNotSplit(start, length, data_file.file_size_in_bytes));
std::vector<std::shared_ptr<DataFile>> delete_files;
if (json.contains(kDeleteFiles)) {
@@ -2539,9 +2533,13 @@ Result<std::shared_ptr<FileScanTask>>
FileScanTaskFromJson(const nlohmann::json&
ICEBERG_ASSIGN_OR_RAISE(residual_filter, ExpressionFromJson(filter_json));
}
- return
std::make_shared<FileScanTask>(std::make_shared<DataFile>(std::move(data_file)),
- std::move(delete_files),
- std::move(residual_filter));
+ auto task =
FileScanTask::MakeSplit(std::make_shared<DataFile>(std::move(data_file)),
+ start, length, std::move(delete_files),
+ std::move(residual_filter));
+ if (!task) {
+ return JsonParseError("Invalid FileScanTask range: {}",
task.error().message);
+ }
+ return std::move(task.value());
}
} // namespace iceberg
diff --git a/src/iceberg/json_serde_internal.h
b/src/iceberg/json_serde_internal.h
index 2465df14..e3736782 100644
--- a/src/iceberg/json_serde_internal.h
+++ b/src/iceberg/json_serde_internal.h
@@ -455,8 +455,8 @@ ICEBERG_EXPORT Result<DataFile> DataFileFromJson(
/// - `schema` (required): schema used by this task
/// - `spec` (required): partition spec used by this task
/// - `data-file` (required): ContentFile JSON
-/// - `start` (required): always 0 because split tasks are unsupported
-/// - `length` (required): always the data file size
+/// - `start` (required): inclusive byte offset where the task starts
+/// - `length` (required): number of bytes in the task range
/// - `delete-files` (optional on input; omitted when the task has no delete
files):
/// array of ContentFile JSON
/// - `residual-filter` (optional): Expression JSON
@@ -469,13 +469,10 @@ ICEBERG_EXPORT Result<nlohmann::json> ToJson(
/// \brief Deserializes a self-contained FileScanTask JSON object.
///
/// Reads the embedded schema and partition spec. Rejects REST
-/// `delete-file-references` and byte-range splits (`start` != 0 or `length`
!= file
-/// size). Both range fields are required, matching Java core.
+/// `delete-file-references`. Both range fields are required, matching Java
core.
+/// Valid ranges are preserved. C++ additionally rejects negative, out-of-file,
+/// and empty partial ranges; an empty range is valid only for a zero-byte
file.
ICEBERG_EXPORT Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(
const nlohmann::json& json);
-/// Rejects `start != 0 || length != file_size_in_bytes`.
-ICEBERG_EXPORT Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
- int64_t file_size_in_bytes);
-
} // namespace iceberg
diff --git a/src/iceberg/table_scan.cc b/src/iceberg/table_scan.cc
index 636834d6..25548bc7 100644
--- a/src/iceberg/table_scan.cc
+++ b/src/iceberg/table_scan.cc
@@ -37,6 +37,8 @@
#include "iceberg/snapshot.h"
#include "iceberg/table.h"
#include "iceberg/table_metadata.h"
+#include "iceberg/util/content_file_util.h"
+#include "iceberg/util/int128.h"
#include "iceberg/util/macros.h"
#include "iceberg/util/snapshot_util.h"
#include "iceberg/util/timepoint.h"
@@ -179,11 +181,78 @@ FileScanTask::FileScanTask(std::shared_ptr<DataFile>
data_file,
ICEBERG_DCHECK(data_file_ != nullptr, "Data file cannot be null for
FileScanTask");
}
-int64_t FileScanTask::size_bytes() const { return
data_file_->file_size_in_bytes; }
+FileScanTask::FileScanTask(std::shared_ptr<DataFile> data_file,
+ std::vector<std::shared_ptr<DataFile>> delete_files,
+ std::shared_ptr<Expression> residual_filter, Range
range)
+ : FileScanTask(std::move(data_file), std::move(delete_files),
+ std::move(residual_filter)) {
+ range_ = range;
+}
+
+Result<std::shared_ptr<FileScanTask>> FileScanTask::MakeSplit(
+ std::shared_ptr<DataFile> data_file, int64_t start, int64_t length,
+ std::vector<std::shared_ptr<DataFile>> delete_files,
+ std::shared_ptr<Expression> filter) {
+ if (data_file == nullptr) {
+ return InvalidArgument("Cannot create a file scan task without a data
file");
+ }
+
+ const int64_t file_size = data_file->file_size_in_bytes;
+ if (file_size < 0) {
+ return InvalidArgument("Cannot split file {} with negative size {}",
+ data_file->file_path, file_size);
+ }
+ if (start < 0 || start > file_size) {
+ return InvalidArgument("Split start {} is outside file {} of size {}",
start,
+ data_file->file_path, file_size);
+ }
+ // Subtraction is safe after validating start, even when file_size is
INT64_MAX.
+ if (length < 0 || length > file_size - start) {
+ return InvalidArgument("Split length {} from start {} is outside file {}
of size {}",
+ length, start, data_file->file_path, file_size);
+ }
+ // A full-file range is a whole-file task (is_split() is false). This is
also the
+ // only valid empty range: [0, 0) of an empty file.
+ if (start == 0 && length == file_size) {
+ return std::make_shared<FileScanTask>(std::move(data_file),
std::move(delete_files),
+ std::move(filter));
+ }
+ if (length == 0) {
+ return InvalidArgument("A partial split of file {} must have positive
length",
+ data_file->file_path);
+ }
+
+ 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}));
+}
+
+int64_t FileScanTask::length() const {
+ return range_ ? range_->length : data_file_->file_size_in_bytes;
+}
+
+int64_t FileScanTask::size_bytes() const { return length(); }
int32_t FileScanTask::files_count() const { return 1; }
-int64_t FileScanTask::estimated_row_count() const { return
data_file_->record_count; }
+int64_t FileScanTask::estimated_row_count() const {
+ if (!range_) {
+ return data_file_->record_count;
+ }
+
+ const int64_t record_count = data_file_->record_count;
+ if (record_count <= 0) {
+ return 0;
+ }
+
+ // floor(end * rows / size) - floor(start * rows / size), so splits that
cover
+ // the file sum exactly 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_start =
+ static_cast<int128_t>(range_->start) * record_count / range_->file_size;
+ return static_cast<int64_t>(count_at_end - count_at_start);
+}
// ChangelogScanTask implementation
diff --git a/src/iceberg/table_scan.h b/src/iceberg/table_scan.h
index abe93bbf..7afaa79d 100644
--- a/src/iceberg/table_scan.h
+++ b/src/iceberg/table_scan.h
@@ -22,6 +22,7 @@
/// \file iceberg/table_scan.h
/// \brief Define table scan APIs and scan task types.
+#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
@@ -71,10 +72,32 @@ 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.
explicit FileScanTask(std::shared_ptr<DataFile> data_file,
std::vector<std::shared_ptr<DataFile>> delete_files =
{},
std::shared_ptr<Expression> filter = nullptr);
+ /// \brief Make a task for a validated half-open byte range [start, start +
length).
+ ///
+ /// 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.
+ 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 = {},
+ std::shared_ptr<Expression> filter = nullptr);
+
+ /// \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.
+ int64_t length() const;
+
+ /// \brief Whether this task reads a proper subset of its data file.
+ bool is_split() const { return range_.has_value(); }
+
/// \brief The data file that should be read by this scan task.
const std::shared_ptr<DataFile>& data_file() const { return data_file_; }
@@ -92,9 +115,22 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
int64_t estimated_row_count() const override;
private:
+ // Only set for a partial file; whole-file tasks leave range_ empty.
+ 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,
+ std::vector<std::shared_ptr<DataFile>> delete_files,
+ std::shared_ptr<Expression> filter, Range range);
+
std::shared_ptr<DataFile> data_file_;
std::vector<std::shared_ptr<DataFile>> delete_files_;
std::shared_ptr<Expression> residual_filter_;
+ std::optional<Range> range_;
};
/// \brief Stream of file scan tasks.
diff --git a/src/iceberg/test/file_scan_task_reader_test.cc
b/src/iceberg/test/file_scan_task_reader_test.cc
index cca13ce0..11366f72 100644
--- a/src/iceberg/test/file_scan_task_reader_test.cc
+++ b/src/iceberg/test/file_scan_task_reader_test.cc
@@ -409,6 +409,25 @@ TEST_F(FileScanTaskReaderTest,
OpenWithoutDeletesReadsProjectedSchema) {
VerifyStream(&stream, R"([[1, "Foo"], [2, "Bar"], [3, "Baz"]])"));
}
+TEST_F(FileScanTaskReaderTest, RejectsPartialTaskUntilRangeReadsAreSupported) {
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto data_file,
+ MakeDataFile(table_schema_,
+ R"([[1, "Foo", "blue"], [2, "Bar", "red"], [3, "Baz",
"green"]])"));
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTask::MakeSplit(data_file, 0, 1));
+
+ FileScanTaskReader::Options options{
+ .io = file_io_,
+ .table_schema = table_schema_,
+ .schemas = {table_schema_},
+ .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"));
+}
+
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 cb945ba1..6394c96f 100644
--- a/src/iceberg/test/file_scan_task_test.cc
+++ b/src/iceberg/test/file_scan_task_test.cc
@@ -17,6 +17,10 @@
* under the License.
*/
+#include <cstdint>
+#include <limits>
+#include <memory>
+
#include <arrow/array.h>
#include <arrow/c/bridge.h>
#include <arrow/json/from_string.h>
@@ -29,6 +33,7 @@
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/data/file_scan_task_reader.h"
+#include "iceberg/expression/expressions.h"
#include "iceberg/file_format.h"
#include "iceberg/manifest/manifest_entry.h"
#include "iceberg/parquet/parquet_register.h"
@@ -40,6 +45,116 @@
namespace iceberg {
+TEST(FileScanTaskRangeTest, WholeFileAndEmptyFile) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_path = "test.parquet";
+ data_file->file_size_in_bytes = 100;
+ data_file->record_count = 7;
+
+ FileScanTask whole(data_file);
+ EXPECT_FALSE(whole.is_split());
+ EXPECT_EQ(whole.start(), 0);
+ EXPECT_EQ(whole.length(), 100);
+ EXPECT_EQ(whole.size_bytes(), 100);
+ EXPECT_EQ(whole.estimated_row_count(), 7);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto explicit_whole,
FileScanTask::MakeSplit(data_file, 0, 100));
+ EXPECT_FALSE(explicit_whole->is_split());
+ EXPECT_EQ(explicit_whole->start(), 0);
+ 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));
+ EXPECT_FALSE(empty->is_split());
+ EXPECT_EQ(empty->start(), 0);
+ EXPECT_EQ(empty->length(), 0);
+ EXPECT_EQ(empty->size_bytes(), 0);
+ EXPECT_EQ(empty->estimated_row_count(), 0);
+}
+
+TEST(FileScanTaskRangeTest, DistinctSplitsShareMetadataAndEstimateRowsOnce) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_path = "test.parquet";
+ data_file->file_size_in_bytes = 100;
+ data_file->record_count = 7;
+ data_file->first_row_id = 1000;
+ data_file->data_sequence_number = 22;
+ auto delete_file = std::make_shared<DataFile>();
+ auto residual = Expressions::AlwaysTrue();
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto first, FileScanTask::MakeSplit(data_file, 0, 40, {delete_file},
residual));
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto second, FileScanTask::MakeSplit(data_file, 40, 60, {delete_file},
residual));
+
+ EXPECT_TRUE(first->is_split());
+ EXPECT_TRUE(second->is_split());
+ EXPECT_EQ(first->start(), 0);
+ EXPECT_EQ(first->length(), 40);
+ EXPECT_EQ(second->start(), 40);
+ EXPECT_EQ(second->length(), 60);
+ EXPECT_EQ(first->size_bytes() + second->size_bytes(), 100);
+ EXPECT_EQ(first->estimated_row_count(), 2);
+ EXPECT_EQ(second->estimated_row_count(), 5);
+ EXPECT_EQ(first->estimated_row_count() + second->estimated_row_count(), 7);
+ EXPECT_EQ(first->data_file(), data_file);
+ EXPECT_EQ(second->data_file(), data_file);
+ ASSERT_EQ(first->delete_files().size(), 1);
+ ASSERT_EQ(second->delete_files().size(), 1);
+ EXPECT_EQ(first->delete_files()[0], delete_file);
+ EXPECT_EQ(second->delete_files()[0], delete_file);
+ EXPECT_EQ(first->residual_filter(), residual);
+ EXPECT_EQ(second->residual_filter(), residual);
+ EXPECT_EQ(first->data_file()->first_row_id, 1000);
+ EXPECT_EQ(first->data_file()->data_sequence_number, 22);
+ EXPECT_EQ(second->data_file()->first_row_id, 1000);
+ EXPECT_EQ(second->data_file()->data_sequence_number, 22);
+}
+
+TEST(FileScanTaskRangeTest, RejectsInvalidRanges) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_path = "test.parquet";
+ data_file->file_size_in_bytes = 100;
+
+ const auto expect_invalid = [&](int64_t start, int64_t length) {
+ auto result = FileScanTask::MakeSplit(data_file, start, length);
+ ASSERT_FALSE(result.has_value());
+ EXPECT_EQ(result.error().kind, ErrorKind::kInvalidArgument);
+ };
+ expect_invalid(-1, 1);
+ expect_invalid(0, -1);
+ expect_invalid(0, 0);
+ expect_invalid(100, 0);
+ expect_invalid(101, 1);
+ expect_invalid(99, 2);
+
+ auto null_file = FileScanTask::MakeSplit(nullptr, 0, 1);
+ ASSERT_FALSE(null_file.has_value());
+ EXPECT_EQ(null_file.error().kind, ErrorKind::kInvalidArgument);
+
+ data_file->file_size_in_bytes = -1;
+ expect_invalid(0, 1);
+
+ data_file->file_size_in_bytes = std::numeric_limits<int64_t>::max();
+ expect_invalid(std::numeric_limits<int64_t>::max() - 1, 2);
+}
+
+TEST(FileScanTaskRangeTest, RowEstimateDoesNotOverflow) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->file_size_in_bytes = std::numeric_limits<int64_t>::max();
+ data_file->record_count = std::numeric_limits<int64_t>::max();
+
+ ICEBERG_UNWRAP_OR_FAIL(
+ auto first,
+ FileScanTask::MakeSplit(data_file, 0, data_file->file_size_in_bytes -
1));
+ ICEBERG_UNWRAP_OR_FAIL(auto last, FileScanTask::MakeSplit(
+ data_file,
data_file->file_size_in_bytes - 1, 1));
+ EXPECT_EQ(first->estimated_row_count(), data_file->record_count - 1);
+ EXPECT_EQ(last->estimated_row_count(), 1);
+}
+
class FileScanTaskTest : public TempFileTestBase {
protected:
static void SetUpTestSuite() { parquet::RegisterAll(); }
diff --git a/src/iceberg/test/json_serde_test.cc
b/src/iceberg/test/json_serde_test.cc
index 51bc2af1..fc7a5ec7 100644
--- a/src/iceberg/test/json_serde_test.cc
+++ b/src/iceberg/test/json_serde_test.cc
@@ -18,6 +18,7 @@
*/
#include <array>
+#include <limits>
#include <memory>
#include <optional>
#include <string>
@@ -1803,31 +1804,113 @@ TEST_F(FileScanTaskJsonTest, RequiresJavaCoreFields) {
}
}
-TEST_F(FileScanTaskJsonTest, RejectsSplitTasksUsingExactOrCondition) {
+TEST_F(FileScanTaskJsonTest, RoundTripsValidRanges) {
struct Case {
int64_t start;
int64_t length;
- bool supported;
+ bool is_split;
};
- for (const auto& test_case : {Case{.start = 0, .length = 10, .supported =
true},
- Case{.start = 1, .length = 10, .supported =
false},
- Case{.start = 0, .length = 9, .supported =
false}}) {
+ for (const auto& test_case : {Case{.start = 0, .length = 10, .is_split =
false},
+ Case{.start = 0, .length = 9, .is_split =
true},
+ Case{.start = 4, .length = 6, .is_split =
true}}) {
SCOPED_TRACE(testing::Message()
<< "start=" << test_case.start << ", length=" <<
test_case.length);
auto json = JavaGolden();
json["start"] = test_case.start;
json["length"] = test_case.length;
+ json["residual-filter"] = R"({"type":"gt","term":"id","value":5})"_json;
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ EXPECT_EQ(task->start(), test_case.start);
+ EXPECT_EQ(task->length(), test_case.length);
+ EXPECT_EQ(task->is_split(), test_case.is_split);
+ ASSERT_NE(task->data_file(), nullptr);
+ EXPECT_EQ(*task->data_file(), DataFileForTask());
+ ASSERT_EQ(task->delete_files().size(), 1U);
+ ASSERT_NE(task->delete_files()[0], nullptr);
+ EXPECT_EQ(*task->delete_files()[0], DeleteFileForTask());
+ ASSERT_NE(task->residual_filter(), nullptr);
+ ICEBERG_UNWRAP_OR_FAIL(auto residual, ToJson(*task->residual_filter()));
+ EXPECT_EQ(residual, json["residual-filter"]);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto serialized, ToJson(*task, specs_, schema_));
+ EXPECT_EQ(serialized, json);
+ ICEBERG_UNWRAP_OR_FAIL(auto reparsed, FileScanTaskFromJson(serialized));
+ EXPECT_EQ(reparsed->start(), test_case.start);
+ EXPECT_EQ(reparsed->length(), test_case.length);
+ EXPECT_EQ(reparsed->is_split(), test_case.is_split);
+ }
+}
+
+TEST_F(FileScanTaskJsonTest, RejectsInvalidRanges) {
+ struct Case {
+ int64_t file_size;
+ int64_t start;
+ int64_t length;
+ };
+ constexpr auto kMax = std::numeric_limits<int64_t>::max();
+ for (const auto& test_case :
+ {// Extends beyond the file.
+ Case{.file_size = 10, .start = 1, .length = 10},
+ // Starts beyond the file.
+ Case{.file_size = 10, .start = 11, .length = 1},
+ // Negative start.
+ Case{.file_size = 10, .start = -1, .length = 1},
+ // Negative length.
+ Case{.file_size = 10, .start = 0, .length = -1},
+ // Empty range in a nonempty file.
+ Case{.file_size = 10, .start = 0, .length = 0},
+ // Empty range at EOF.
+ Case{.file_size = 10, .start = 10, .length = 0},
+ // Nonempty range in an empty file.
+ Case{.file_size = 0, .start = 0, .length = 1},
+ // Negative file size.
+ Case{.file_size = -1, .start = 0, .length = 0},
+ // End would overflow int64_t.
+ Case{.file_size = kMax, .start = kMax - 1, .length = 2}}) {
+ SCOPED_TRACE(testing::Message()
+ << "file_size=" << test_case.file_size << ", start=" <<
test_case.start
+ << ", length=" << test_case.length);
+ auto json = JavaGolden();
+ json["data-file"]["file-size-in-bytes"] = test_case.file_size;
+ json["start"] = test_case.start;
+ json["length"] = test_case.length;
auto result = FileScanTaskFromJson(json);
- if (test_case.supported) {
- EXPECT_THAT(result, IsOk());
- } else {
- EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
- EXPECT_THAT(result, HasErrorMessage("Split FileScanTask is not
supported"));
- }
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
}
}
+TEST_F(FileScanTaskJsonTest, RejectsSplitOutsideChangedDataFile) {
+ auto data_file = std::make_shared<DataFile>(DataFileForTask());
+ ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 4, 6));
+ data_file->file_size_in_bytes = 9;
+
+ auto result = ToJson(*split, specs_, schema_);
+ EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
+}
+
+TEST_F(FileScanTaskJsonTest, RoundTripsEmptyWholeFile) {
+ auto json = JavaGolden();
+ json["data-file"]["file-size-in-bytes"] = 0;
+ json["data-file"]["record-count"] = 0;
+ json["data-file"].erase("lower-bounds");
+ json["data-file"].erase("upper-bounds");
+ json.erase("delete-files");
+ json["start"] = 0;
+ json["length"] = 0;
+
+ ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTaskFromJson(json));
+ EXPECT_EQ(task->start(), 0);
+ EXPECT_EQ(task->length(), 0);
+ EXPECT_FALSE(task->is_split());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto serialized, ToJson(*task, specs_, schema_));
+ EXPECT_EQ(serialized, json);
+}
+
TEST_F(FileScanTaskJsonTest, RejectsFractionalIntegerFields) {
for (std::string_view field :
{"start", "length", "spec-id", "file-size-in-bytes", "record-count",
diff --git a/src/iceberg/test/rest_json_serde_test.cc
b/src/iceberg/test/rest_json_serde_test.cc
index db19860a..1a593ffc 100644
--- a/src/iceberg/test/rest_json_serde_test.cc
+++ b/src/iceberg/test/rest_json_serde_test.cc
@@ -17,6 +17,7 @@
* under the License.
*/
+#include <limits>
#include <memory>
#include <string>
#include <string_view>
@@ -2171,6 +2172,9 @@ TEST(FileScanTasksFromJsonTest, SingleTaskNoDeleteFiles) {
const auto& task = result.value()[0];
ASSERT_NE(task->data_file(), nullptr);
EXPECT_EQ(task->data_file()->file_path, "s3://bucket/data/file.parquet");
+ EXPECT_EQ(task->start(), 0);
+ EXPECT_EQ(task->length(), 12345);
+ EXPECT_FALSE(task->is_split());
EXPECT_TRUE(task->delete_files().empty());
EXPECT_EQ(task->residual_filter(), nullptr);
}
@@ -2190,11 +2194,15 @@ TEST(FileScanTasksFromJsonTest,
AcceptsWholeFileStartAndLength) {
"length": 12345
}])"_json;
- EXPECT_THAT(FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({},
0)),
- IsOk());
+ auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ ASSERT_THAT(result, IsOk());
+ ASSERT_EQ(result.value().size(), 1U);
+ EXPECT_EQ(result.value()[0]->start(), 0);
+ EXPECT_EQ(result.value()[0]->length(), 12345);
+ EXPECT_FALSE(result.value()[0]->is_split());
}
-TEST(FileScanTasksFromJsonTest, NullStartAndLengthUseWholeFileDefaults) {
+TEST(FileScanTasksFromJsonTest, RejectsNullOrMissingRangeEndpoint) {
auto json = R"([{
"data-file": {
"content": "data",
@@ -2205,12 +2213,25 @@ TEST(FileScanTasksFromJsonTest,
NullStartAndLengthUseWholeFileDefaults) {
"file-size-in-bytes": 12345,
"record-count": 100
},
- "start": null,
- "length": null
+ "start": 0,
+ "length": 12345
}])"_json;
- EXPECT_THAT(FileScanTasksFromJson(json, {}, UnpartitionedSpecs(), Schema({},
0)),
- IsOk());
+ for (std::string_view missing_field : {"start", "length"}) {
+ SCOPED_TRACE(missing_field);
+ auto incomplete_json = json;
+ incomplete_json[0].erase(std::string(missing_field));
+ auto result =
+ FileScanTasksFromJson(incomplete_json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("must be provided together"));
+ }
+
+ json[0]["start"] = nullptr;
+ json[0]["length"] = nullptr;
+ auto null_result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ EXPECT_THAT(null_result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(null_result, HasErrorMessage("Missing 'start'"));
}
TEST(FileScanTasksFromJsonTest, RejectsInvalidRangeIntegers) {
@@ -2246,7 +2267,7 @@ TEST(FileScanTasksFromJsonTest,
RejectsInvalidRangeIntegers) {
}
}
-TEST(FileScanTasksFromJsonTest, RejectsSplitTaskWhenEitherRangeFieldDiffers) {
+TEST(FileScanTasksFromJsonTest, AcceptsValidPartialRanges) {
auto json = R"([{
"data-file": {
"content": "data",
@@ -2261,17 +2282,85 @@ TEST(FileScanTasksFromJsonTest,
RejectsSplitTaskWhenEitherRangeFieldDiffers) {
"length": 12345
}])"_json;
- for (const auto& [field, value] :
- {std::pair<std::string_view, int64_t>{"start", 100},
- std::pair<std::string_view, int64_t>{"length", 200}}) {
- SCOPED_TRACE(field);
+ for (const auto& [start, length] :
+ {std::pair<int64_t, int64_t>{0, 200}, {100, 200}, {12245, 100}}) {
+ SCOPED_TRACE(testing::Message() << "start=" << start << ", length=" <<
length);
auto split_json = json;
- split_json[0][field] = value;
+ split_json[0]["start"] = start;
+ split_json[0]["length"] = length;
auto result =
FileScanTasksFromJson(split_json, {}, UnpartitionedSpecs(), Schema({},
0));
- EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
- EXPECT_THAT(result, HasErrorMessage("Split FileScanTask is not
supported"));
+ ASSERT_THAT(result, IsOk());
+ ASSERT_EQ(result.value().size(), 1U);
+ EXPECT_EQ(result.value()[0]->start(), start);
+ EXPECT_EQ(result.value()[0]->length(), length);
+ EXPECT_TRUE(result.value()[0]->is_split());
+ }
+}
+
+TEST(FileScanTasksFromJsonTest, RejectsInvalidRanges) {
+ auto json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 0,
+ "length": 12345
+ }])"_json;
+
+ for (const auto& [start, length] : {std::pair<int64_t, int64_t>{-1, 100},
+ {0, -1},
+ {0, 0},
+ {12345, 0},
+ {12345, 1},
+ {12000, 500}}) {
+ SCOPED_TRACE(testing::Message() << "start=" << start << ", length=" <<
length);
+ auto invalid_json = json;
+ invalid_json[0]["start"] = start;
+ invalid_json[0]["length"] = length;
+ auto result =
+ FileScanTasksFromJson(invalid_json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ EXPECT_THAT(result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
}
+
+ auto overflow_json = json;
+ overflow_json[0]["data-file"]["file-size-in-bytes"] =
+ std::numeric_limits<int64_t>::max();
+ overflow_json[0]["start"] = std::numeric_limits<int64_t>::max() - 1;
+ overflow_json[0]["length"] = 2;
+ auto overflow_result =
+ FileScanTasksFromJson(overflow_json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ EXPECT_THAT(overflow_result, IsError(ErrorKind::kJsonParseError));
+ EXPECT_THAT(overflow_result, HasErrorMessage("Invalid FileScanTask range"));
+}
+
+TEST(FileScanTasksFromJsonTest, AcceptsEmptyWholeFile) {
+ auto json = R"([{
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/empty.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 0,
+ "record-count": 0
+ },
+ "start": 0,
+ "length": 0
+ }])"_json;
+
+ auto result = FileScanTasksFromJson(json, {}, UnpartitionedSpecs(),
Schema({}, 0));
+ ASSERT_THAT(result, IsOk());
+ ASSERT_EQ(result.value().size(), 1U);
+ EXPECT_EQ(result.value()[0]->start(), 0);
+ EXPECT_EQ(result.value()[0]->length(), 0);
+ EXPECT_FALSE(result.value()[0]->is_split());
}
TEST(FileScanTasksFromJsonTest, RowLineageSequence) {
@@ -2444,6 +2533,80 @@ TEST(FetchScanTasksResponseRoundtripTest,
WithFileScanTasksAndDeleteFiles) {
EXPECT_EQ(*result, *result2);
}
+TEST(FetchScanTasksResponseRoundtripTest, PreservesDistinctSplitRanges) {
+ auto json = nlohmann::json::parse(R"({
+ "file-scan-tasks": [
+ {
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 0,
+ "length": 5000
+ },
+ {
+ "data-file": {
+ "content": "data",
+ "file-path": "s3://bucket/data/file.parquet",
+ "file-format": "PARQUET",
+ "spec-id": 0,
+ "partition": [],
+ "file-size-in-bytes": 12345,
+ "record-count": 100
+ },
+ "start": 5000,
+ "length": 7345
+ }
+ ]
+ })");
+
+ auto result = FetchScanTasksResponseFromJson(json, UnpartitionedSpecs(),
EmptySchema());
+ ASSERT_THAT(result, IsOk());
+ ASSERT_TRUE(result.value().file_scan_tasks.has_value());
+ ASSERT_EQ(result.value().file_scan_tasks->size(), 2U);
+ EXPECT_EQ(result.value().file_scan_tasks->at(0)->start(), 0);
+ EXPECT_EQ(result.value().file_scan_tasks->at(0)->length(), 5000);
+ EXPECT_EQ(result.value().file_scan_tasks->at(1)->start(), 5000);
+ EXPECT_EQ(result.value().file_scan_tasks->at(1)->length(), 7345);
+
+ ICEBERG_UNWRAP_OR_FAIL(auto roundtrip_json,
+ ToJson(result.value(), UnpartitionedSpecs(),
EmptySchema()));
+ EXPECT_EQ(roundtrip_json["file-scan-tasks"][0]["start"], 0);
+ EXPECT_EQ(roundtrip_json["file-scan-tasks"][0]["length"], 5000);
+ EXPECT_EQ(roundtrip_json["file-scan-tasks"][1]["start"], 5000);
+ EXPECT_EQ(roundtrip_json["file-scan-tasks"][1]["length"], 7345);
+
+ auto result2 =
+ FetchScanTasksResponseFromJson(roundtrip_json, UnpartitionedSpecs(),
EmptySchema());
+ ASSERT_THAT(result2, IsOk());
+ EXPECT_EQ(result.value(), result2.value());
+}
+
+TEST(FetchScanTasksResponseRoundtripTest, RejectsSplitOutsideChangedDataFile) {
+ auto data_file = std::make_shared<DataFile>();
+ data_file->content = DataFile::Content::kData;
+ data_file->file_path = "s3://bucket/data/file.parquet";
+ data_file->file_format = FileFormatType::kParquet;
+ data_file->partition_spec_id = PartitionSpec::kInitialSpecId;
+ data_file->partition = PartitionValues{};
+ data_file->file_size_in_bytes = 12345;
+ data_file->record_count = 100;
+ ICEBERG_UNWRAP_OR_FAIL(auto split, FileScanTask::MakeSplit(data_file, 5000,
7345));
+
+ FetchScanTasksResponse response;
+ response.file_scan_tasks = std::vector<std::shared_ptr<FileScanTask>>{split};
+ data_file->file_size_in_bytes = 12000;
+
+ auto result = ToJson(response, UnpartitionedSpecs(), EmptySchema());
+ EXPECT_THAT(result, IsError(ErrorKind::kValidationFailed));
+ EXPECT_THAT(result, HasErrorMessage("Invalid FileScanTask range"));
+}
+
TEST(FetchScanTasksResponseRoundtripTest, PreservesResidualFilter) {
auto json = nlohmann::json::parse(R"({
"file-scan-tasks": [