Skip to content

Commit 26ae9ea

Browse files
kamcheungting-dbIsaac
andauthored
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 <no-reply@databricks.com>
1 parent f87954d commit 26ae9ea

12 files changed

Lines changed: 575 additions & 65 deletions

‎src/iceberg/catalog/rest/json_serde.cc‎

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ constexpr std::string_view kDeleteFileReferences = "delete-file-references";
115115
constexpr std::string_view kResidualFilter = "residual-filter";
116116
constexpr std::string_view kStart = "start";
117117
constexpr std::string_view kLength = "length";
118+
constexpr std::string_view kMapKeys = "keys";
119+
constexpr std::string_view kMapValues = "values";
118120

119121
Result<nlohmann::json> StorageCredentialToJson(const StorageCredential& credential) {
120122
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -187,14 +189,19 @@ Result<std::vector<std::shared_ptr<FileScanTask>>> FileScanTasksFromJson(
187189
ICEBERG_ASSIGN_OR_RAISE(auto data_file_json,
188190
GetJsonValue<nlohmann::json>(task_json, kDataFile));
189191
ICEBERG_ASSIGN_OR_RAISE(
190-
auto data_file,
191-
iceberg::DataFileFromJson(data_file_json, partition_spec_by_id, schema));
192-
ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonIntegerOrDefault(task_json, kStart, 0));
193-
ICEBERG_ASSIGN_OR_RAISE(
194-
auto length,
195-
GetJsonIntegerOrDefault(task_json, kLength, data_file.file_size_in_bytes));
196-
ICEBERG_RETURN_UNEXPECTED(
197-
CheckFileScanTaskNotSplit(start, length, data_file.file_size_in_bytes));
192+
auto data_file, DataFileFromJson(data_file_json, partition_spec_by_id, schema));
193+
// Neither field means the whole file. Only one field is ambiguous.
194+
const bool has_start = task_json.contains(kStart);
195+
const bool has_length = task_json.contains(kLength);
196+
if (has_start != has_length) {
197+
return JsonParseError("'start' and 'length' must be provided together");
198+
}
199+
int64_t start = 0;
200+
int64_t length = data_file.file_size_in_bytes;
201+
if (has_start) {
202+
ICEBERG_ASSIGN_OR_RAISE(start, GetJsonInteger<int64_t>(task_json, kStart));
203+
ICEBERG_ASSIGN_OR_RAISE(length, GetJsonInteger<int64_t>(task_json, kLength));
204+
}
198205
// FIXME: REST scan-task DataFile JSON currently carries first-row-id,
199206
// but not the manifest-entry data sequence number. Until the REST API exposes
200207
// it, REST-planned tasks cannot inherit _last_updated_sequence_number.
@@ -222,9 +229,13 @@ Result<std::vector<std::shared_ptr<FileScanTask>>> FileScanTasksFromJson(
222229
ICEBERG_ASSIGN_OR_RAISE(residual_filter, ExpressionFromJson(filter_json));
223230
}
224231

225-
file_scan_tasks.push_back(std::make_shared<FileScanTask>(
226-
std::make_shared<DataFile>(std::move(data_file)), std::move(task_delete_files),
227-
std::move(residual_filter)));
232+
auto task = FileScanTask::MakeSplit(std::make_shared<DataFile>(std::move(data_file)),
233+
start, length, std::move(task_delete_files),
234+
std::move(residual_filter));
235+
if (!task) {
236+
return JsonParseError("Invalid FileScanTask range: {}", task.error().message);
237+
}
238+
file_scan_tasks.push_back(std::move(task.value()));
228239
}
229240
return file_scan_tasks;
230241
}
@@ -272,10 +283,19 @@ Result<nlohmann::json> ScanTaskFieldsToJson(
272283
if (!task) continue;
273284
nlohmann::json task_json;
274285
if (task->data_file()) {
286+
// The shared data file may have changed size; don't write an invalid range.
287+
auto range_result =
288+
FileScanTask::MakeSplit(task->data_file(), task->start(), task->length());
289+
if (!range_result) {
290+
return ValidationFailed("Invalid FileScanTask range: {}",
291+
range_result.error().message);
292+
}
275293
ICEBERG_ASSIGN_OR_RAISE(
276294
auto data_file_json,
277295
iceberg::ToJson(*task->data_file(), partition_specs_by_id, schema));
278296
task_json[kDataFile] = std::move(data_file_json);
297+
task_json[kStart] = task->start();
298+
task_json[kLength] = task->length();
279299
}
280300
if (!task->delete_files().empty()) {
281301
std::vector<int32_t> refs;

‎src/iceberg/catalog/rest/types.cc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ bool FileScanTaskEqual(const std::shared_ptr<FileScanTask>& lhs,
192192
return false;
193193
}
194194
return SharedPtrEqual(lhs->data_file(), rhs->data_file()) &&
195+
lhs->start() == rhs->start() && lhs->length() == rhs->length() &&
195196
SharedPtrVectorEqual(lhs->delete_files(), rhs->delete_files()) &&
196197
ExpressionPtrEqual(lhs->residual_filter(), rhs->residual_filter());
197198
}

‎src/iceberg/data/file_scan_task_reader.cc‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ class FileScanTaskReader::Impl {
163163
ICEBERG_PRECHECK(data_file->file_size_in_bytes >= 0,
164164
"Data file size must not be negative: {}",
165165
data_file->file_size_in_bytes);
166+
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());
171+
}
166172

167173
if (task.delete_files().empty()) {
168174
auto options = MakeReaderOptions(

‎src/iceberg/data/file_scan_task_reader.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class ICEBERG_DATA_EXPORT FileScanTaskReader {
6767
~FileScanTaskReader();
6868

6969
/// \brief Open a task and return an Arrow C stream for its projected live rows.
70+
///
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.
7073
Result<ArrowArrayStream> Open(const FileScanTask& task);
7174

7275
FileScanTaskReader(const FileScanTaskReader&) = delete;

‎src/iceberg/json_serde.cc‎

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2408,6 +2408,13 @@ Result<nlohmann::json> ToJson(
24082408
if (task.data_file()->content != DataFile::Content::kData) {
24092409
return ValidationFailed("FileScanTask data-file must have data content");
24102410
}
2411+
// The shared data file may have changed size; don't write an invalid range.
2412+
auto range_result =
2413+
FileScanTask::MakeSplit(task.data_file(), task.start(), task.length());
2414+
if (!range_result) {
2415+
return ValidationFailed("Invalid FileScanTask range: {}",
2416+
range_result.error().message);
2417+
}
24112418

24122419
nlohmann::json json;
24132420
json[kTaskType] = kFileScanTaskType;
@@ -2423,8 +2430,8 @@ Result<nlohmann::json> ToJson(
24232430
}
24242431
json[kSpec] = ToJson(*spec_it->second);
24252432
json[kDataFile] = std::move(data_file_json);
2426-
json[kStart] = 0;
2427-
json[kLength] = task.data_file()->file_size_in_bytes;
2433+
json[kStart] = task.start();
2434+
json[kLength] = task.length();
24282435

24292436
nlohmann::json delete_files_json = nlohmann::json::array();
24302437
for (const auto& delete_file : task.delete_files()) {
@@ -2457,17 +2464,6 @@ Result<nlohmann::json> ToJson(
24572464
return json;
24582465
}
24592466

2460-
Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
2461-
int64_t file_size_in_bytes) {
2462-
if (start != 0 || length != file_size_in_bytes) {
2463-
return NotSupported(
2464-
"Split FileScanTask is not supported: start={}, length={}, "
2465-
"file-size-in-bytes={}",
2466-
start, length, file_size_in_bytes);
2467-
}
2468-
return {};
2469-
}
2470-
24712467
Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(const nlohmann::json& json) {
24722468
if (!json.is_object()) {
24732469
return JsonParseError("Cannot parse file scan task from a non-object: {}",
@@ -2510,8 +2506,6 @@ Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(const nlohmann::json&
25102506

25112507
ICEBERG_ASSIGN_OR_RAISE(auto start, GetJsonInteger<int64_t>(json, kStart));
25122508
ICEBERG_ASSIGN_OR_RAISE(auto length, GetJsonInteger<int64_t>(json, kLength));
2513-
ICEBERG_RETURN_UNEXPECTED(
2514-
CheckFileScanTaskNotSplit(start, length, data_file.file_size_in_bytes));
25152509

25162510
std::vector<std::shared_ptr<DataFile>> delete_files;
25172511
if (json.contains(kDeleteFiles)) {
@@ -2539,9 +2533,13 @@ Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(const nlohmann::json&
25392533
ICEBERG_ASSIGN_OR_RAISE(residual_filter, ExpressionFromJson(filter_json));
25402534
}
25412535

2542-
return std::make_shared<FileScanTask>(std::make_shared<DataFile>(std::move(data_file)),
2543-
std::move(delete_files),
2544-
std::move(residual_filter));
2536+
auto task = FileScanTask::MakeSplit(std::make_shared<DataFile>(std::move(data_file)),
2537+
start, length, std::move(delete_files),
2538+
std::move(residual_filter));
2539+
if (!task) {
2540+
return JsonParseError("Invalid FileScanTask range: {}", task.error().message);
2541+
}
2542+
return std::move(task.value());
25452543
}
25462544

25472545
} // namespace iceberg

‎src/iceberg/json_serde_internal.h‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -455,8 +455,8 @@ ICEBERG_EXPORT Result<DataFile> DataFileFromJson(
455455
/// - `schema` (required): schema used by this task
456456
/// - `spec` (required): partition spec used by this task
457457
/// - `data-file` (required): ContentFile JSON
458-
/// - `start` (required): always 0 because split tasks are unsupported
459-
/// - `length` (required): always the data file size
458+
/// - `start` (required): inclusive byte offset where the task starts
459+
/// - `length` (required): number of bytes in the task range
460460
/// - `delete-files` (optional on input; omitted when the task has no delete files):
461461
/// array of ContentFile JSON
462462
/// - `residual-filter` (optional): Expression JSON
@@ -469,13 +469,10 @@ ICEBERG_EXPORT Result<nlohmann::json> ToJson(
469469
/// \brief Deserializes a self-contained FileScanTask JSON object.
470470
///
471471
/// Reads the embedded schema and partition spec. Rejects REST
472-
/// `delete-file-references` and byte-range splits (`start` != 0 or `length` != file
473-
/// size). Both range fields are required, matching Java core.
472+
/// `delete-file-references`. Both range fields are required, matching Java core.
473+
/// Valid ranges are preserved. C++ additionally rejects negative, out-of-file,
474+
/// and empty partial ranges; an empty range is valid only for a zero-byte file.
474475
ICEBERG_EXPORT Result<std::shared_ptr<FileScanTask>> FileScanTaskFromJson(
475476
const nlohmann::json& json);
476477

477-
/// Rejects `start != 0 || length != file_size_in_bytes`.
478-
ICEBERG_EXPORT Status CheckFileScanTaskNotSplit(int64_t start, int64_t length,
479-
int64_t file_size_in_bytes);
480-
481478
} // namespace iceberg

‎src/iceberg/table_scan.cc‎

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
#include "iceberg/snapshot.h"
3838
#include "iceberg/table.h"
3939
#include "iceberg/table_metadata.h"
40+
#include "iceberg/util/content_file_util.h"
41+
#include "iceberg/util/int128.h"
4042
#include "iceberg/util/macros.h"
4143
#include "iceberg/util/snapshot_util.h"
4244
#include "iceberg/util/timepoint.h"
@@ -179,11 +181,78 @@ FileScanTask::FileScanTask(std::shared_ptr<DataFile> data_file,
179181
ICEBERG_DCHECK(data_file_ != nullptr, "Data file cannot be null for FileScanTask");
180182
}
181183

182-
int64_t FileScanTask::size_bytes() const { return data_file_->file_size_in_bytes; }
184+
FileScanTask::FileScanTask(std::shared_ptr<DataFile> data_file,
185+
std::vector<std::shared_ptr<DataFile>> delete_files,
186+
std::shared_ptr<Expression> residual_filter, Range range)
187+
: FileScanTask(std::move(data_file), std::move(delete_files),
188+
std::move(residual_filter)) {
189+
range_ = range;
190+
}
191+
192+
Result<std::shared_ptr<FileScanTask>> FileScanTask::MakeSplit(
193+
std::shared_ptr<DataFile> data_file, int64_t start, int64_t length,
194+
std::vector<std::shared_ptr<DataFile>> delete_files,
195+
std::shared_ptr<Expression> filter) {
196+
if (data_file == nullptr) {
197+
return InvalidArgument("Cannot create a file scan task without a data file");
198+
}
199+
200+
const int64_t file_size = data_file->file_size_in_bytes;
201+
if (file_size < 0) {
202+
return InvalidArgument("Cannot split file {} with negative size {}",
203+
data_file->file_path, file_size);
204+
}
205+
if (start < 0 || start > file_size) {
206+
return InvalidArgument("Split start {} is outside file {} of size {}", start,
207+
data_file->file_path, file_size);
208+
}
209+
// Subtraction is safe after validating start, even when file_size is INT64_MAX.
210+
if (length < 0 || length > file_size - start) {
211+
return InvalidArgument("Split length {} from start {} is outside file {} of size {}",
212+
length, start, data_file->file_path, file_size);
213+
}
214+
// A full-file range is a whole-file task (is_split() is false). This is also the
215+
// only valid empty range: [0, 0) of an empty file.
216+
if (start == 0 && length == file_size) {
217+
return std::make_shared<FileScanTask>(std::move(data_file), std::move(delete_files),
218+
std::move(filter));
219+
}
220+
if (length == 0) {
221+
return InvalidArgument("A partial split of file {} must have positive length",
222+
data_file->file_path);
223+
}
224+
225+
return std::shared_ptr<FileScanTask>(
226+
new FileScanTask(std::move(data_file), std::move(delete_files), std::move(filter),
227+
Range{.start = start, .length = length, .file_size = file_size}));
228+
}
229+
230+
int64_t FileScanTask::length() const {
231+
return range_ ? range_->length : data_file_->file_size_in_bytes;
232+
}
233+
234+
int64_t FileScanTask::size_bytes() const { return length(); }
183235

184236
int32_t FileScanTask::files_count() const { return 1; }
185237

186-
int64_t FileScanTask::estimated_row_count() const { return data_file_->record_count; }
238+
int64_t FileScanTask::estimated_row_count() const {
239+
if (!range_) {
240+
return data_file_->record_count;
241+
}
242+
243+
const int64_t record_count = data_file_->record_count;
244+
if (record_count <= 0) {
245+
return 0;
246+
}
247+
248+
// floor(end * rows / size) - floor(start * rows / size), so splits that cover
249+
// the file sum exactly to record_count. int128 avoids overflow.
250+
const auto end = static_cast<int128_t>(range_->start) + range_->length;
251+
const auto count_at_end = end * record_count / range_->file_size;
252+
const auto count_at_start =
253+
static_cast<int128_t>(range_->start) * record_count / range_->file_size;
254+
return static_cast<int64_t>(count_at_end - count_at_start);
255+
}
187256

188257
// ChangelogScanTask implementation
189258

‎src/iceberg/table_scan.h‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
/// \file iceberg/table_scan.h
2323
/// \brief Define table scan APIs and scan task types.
2424

25+
#include <cstdint>
2526
#include <functional>
2627
#include <memory>
2728
#include <optional>
@@ -71,10 +72,32 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
7172
/// \param data_file The data file to read.
7273
/// \param delete_files Delete files that apply to this data file.
7374
/// \param filter Optional residual filter to apply after reading.
75+
/// \note The data file, delete files, and residual filter must not be mutated while
76+
/// a planning or reading stream uses this task.
7477
explicit FileScanTask(std::shared_ptr<DataFile> data_file,
7578
std::vector<std::shared_ptr<DataFile>> delete_files = {},
7679
std::shared_ptr<Expression> filter = nullptr);
7780

81+
/// \brief Make a task for a validated half-open byte range [start, start + length).
82+
///
83+
/// A range covering the entire file is represented as a whole-file task. An empty
84+
/// range is valid only for an empty whole file. The data file, delete files, and
85+
/// residual filter are shared with the resulting task and must not be mutated
86+
/// while a planning or reading stream uses it.
87+
static Result<std::shared_ptr<FileScanTask>> MakeSplit(
88+
std::shared_ptr<DataFile> data_file, int64_t start, int64_t length,
89+
std::vector<std::shared_ptr<DataFile>> delete_files = {},
90+
std::shared_ptr<Expression> filter = nullptr);
91+
92+
/// \brief The inclusive byte offset where this task starts, or zero for a whole file.
93+
int64_t start() const { return range_ ? range_->start : 0; }
94+
95+
/// \brief The byte length of this task, or the current size of a whole file.
96+
int64_t length() const;
97+
98+
/// \brief Whether this task reads a proper subset of its data file.
99+
bool is_split() const { return range_.has_value(); }
100+
78101
/// \brief The data file that should be read by this scan task.
79102
const std::shared_ptr<DataFile>& data_file() const { return data_file_; }
80103

@@ -92,9 +115,22 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
92115
int64_t estimated_row_count() const override;
93116

94117
private:
118+
// Only set for a partial file; whole-file tasks leave range_ empty.
119+
struct Range {
120+
int64_t start;
121+
int64_t length;
122+
// Size when the range was validated. Always positive, so safe to divide by.
123+
int64_t file_size;
124+
};
125+
126+
FileScanTask(std::shared_ptr<DataFile> data_file,
127+
std::vector<std::shared_ptr<DataFile>> delete_files,
128+
std::shared_ptr<Expression> filter, Range range);
129+
95130
std::shared_ptr<DataFile> data_file_;
96131
std::vector<std::shared_ptr<DataFile>> delete_files_;
97132
std::shared_ptr<Expression> residual_filter_;
133+
std::optional<Range> range_;
98134
};
99135

100136
/// \brief Stream of file scan tasks.

‎src/iceberg/test/file_scan_task_reader_test.cc‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,25 @@ TEST_F(FileScanTaskReaderTest, OpenWithoutDeletesReadsProjectedSchema) {
409409
VerifyStream(&stream, R"([[1, "Foo"], [2, "Bar"], [3, "Baz"]])"));
410410
}
411411

412+
TEST_F(FileScanTaskReaderTest, RejectsPartialTaskUntilRangeReadsAreSupported) {
413+
ICEBERG_UNWRAP_OR_FAIL(
414+
auto data_file,
415+
MakeDataFile(table_schema_,
416+
R"([[1, "Foo", "blue"], [2, "Bar", "red"], [3, "Baz", "green"]])"));
417+
ICEBERG_UNWRAP_OR_FAIL(auto task, FileScanTask::MakeSplit(data_file, 0, 1));
418+
419+
FileScanTaskReader::Options options{
420+
.io = file_io_,
421+
.table_schema = table_schema_,
422+
.schemas = {table_schema_},
423+
.projected_schema = projected_schema_,
424+
};
425+
ICEBERG_UNWRAP_OR_FAIL(auto reader, FileScanTaskReader::Make(std::move(options)));
426+
auto result = reader->Open(*task);
427+
EXPECT_THAT(result, IsError(ErrorKind::kNotSupported));
428+
EXPECT_THAT(result, HasErrorMessage("Reading a partial FileScanTask"));
429+
}
430+
412431
TEST_F(FileScanTaskReaderTest, ReadLastUpdatedFromDataSeq) {
413432
ICEBERG_UNWRAP_OR_FAIL(
414433
auto data_file,

0 commit comments

Comments
 (0)