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 a899511c fix(arrow): make full batch validation configurable (#1043)
a899511c is described below

commit a899511ce9eda52e2b79cdb2abfaefccd4841ea3
Author: Guotao Yu <[email protected]>
AuthorDate: Fri Oct 30 17:33:57 2026 +0800

    fix(arrow): make full batch validation configurable (#1043)
---
 src/iceberg/arrow_c_data_util.cc               |  45 ++++++----
 src/iceberg/arrow_c_data_util_internal.h       |  17 ++--
 src/iceberg/data/file_scan_task_reader.cc      |   8 +-
 src/iceberg/file_reader.h                      |   3 +
 src/iceberg/test/arrow_c_data_util_test.cc     | 113 ++++++++++++++++++++-----
 src/iceberg/test/file_scan_task_reader_test.cc |  43 ++++++++++
 6 files changed, 185 insertions(+), 44 deletions(-)

diff --git a/src/iceberg/arrow_c_data_util.cc b/src/iceberg/arrow_c_data_util.cc
index eb7e90b7..915eeabc 100644
--- a/src/iceberg/arrow_c_data_util.cc
+++ b/src/iceberg/arrow_c_data_util.cc
@@ -238,7 +238,8 @@ ProjectionContext::ProjectionContext(ProjectionContext&& 
other) noexcept
       input_arrow_schema_(other.input_arrow_schema_),
       output_arrow_schema_(other.output_arrow_schema_),
       project_batch_function_(std::exchange(other.project_batch_function_, 
nullptr)),
-      project_batch_state_(std::move(other.project_batch_state_)) {
+      project_batch_state_(std::move(other.project_batch_state_)),
+      validate_batch_(other.validate_batch_) {
   other.input_arrow_schema_.release = nullptr;
   other.output_arrow_schema_.release = nullptr;
 }
@@ -264,6 +265,7 @@ ProjectionContext& 
ProjectionContext::operator=(ProjectionContext&& other) noexc
   other.output_arrow_schema_.release = nullptr;
   project_batch_function_ = std::exchange(other.project_batch_function_, 
nullptr);
   project_batch_state_ = std::move(other.project_batch_state_);
+  validate_batch_ = other.validate_batch_;
   return *this;
 }
 
@@ -278,7 +280,7 @@ ProjectionContext::~ProjectionContext() {
 
 Result<ProjectionContext> ProjectionContext::Make(
     const Schema& input_schema, const Schema& output_schema,
-    ProjectionContext::ProjectBatchFunction project_batch_function) {
+    ProjectionContext::ProjectBatchFunction project_batch_function, bool 
validate_batch) {
   ICEBERG_ASSIGN_OR_RAISE(
       auto selected_field_indices,
       BuildSelectedFieldIndices(input_schema.fields(), 
output_schema.fields()));
@@ -290,6 +292,7 @@ Result<ProjectionContext> ProjectionContext::Make(
   ICEBERG_RETURN_UNEXPECTED(ToArrowSchema(input_schema, 
&context.input_arrow_schema_));
   ICEBERG_RETURN_UNEXPECTED(ToArrowSchema(output_schema, 
&context.output_arrow_schema_));
   context.project_batch_function_ = project_batch_function;
+  context.validate_batch_ = validate_batch;
 
   return context;
 }
@@ -319,6 +322,8 @@ ProjectionContext::ProjectBatchState& 
ProjectionContext::project_batch_state() {
   return project_batch_state_;
 }
 
+bool ProjectionContext::validate_batch() const { return validate_batch_; }
+
 void ProjectionContext::RegisterProjectBatchFunction(
     ProjectionContext::ProjectBatchFunction project_batch_function) {
   ICEBERG_DCHECK(project_batch_function != nullptr,
@@ -341,21 +346,13 @@ auto ProjectionContext::ResolveProjectBatchFunction()
 
 namespace {
 
-Result<ArrowArray> ProjectBatchNanoarrow(
-    const ArrowSchema& input_arrow_schema, const ArrowArray& input_batch,
-    std::span<const int32_t> row_indices, const ArrowSchema& 
output_arrow_schema,
-    std::span<const int32_t> selected_field_indices) {
-  ArrowArrayView input_view;
+Result<ArrowArray> ProjectBatchNanoarrow(const ArrowSchema& input_arrow_schema,
+                                         const ArrowArray& input_batch,
+                                         std::span<const int32_t> row_indices,
+                                         const ArrowSchema& 
output_arrow_schema,
+                                         std::span<const int32_t> 
selected_field_indices,
+                                         const ArrowArrayView& input_view) {
   ArrowError error;
-  ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
-      ArrowArrayViewInitFromSchema(&input_view, &input_arrow_schema, &error), 
error);
-  internal::ArrowArrayViewGuard input_view_guard(&input_view);
-  ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
-      ArrowArrayViewSetArray(&input_view, &input_batch, &error), error);
-  ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
-      ArrowArrayViewValidate(&input_view, NANOARROW_VALIDATION_LEVEL_DEFAULT, 
&error),
-      error);
-
   ArrowArray output_array;
   ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
       ArrowArrayInitFromSchema(&output_array, &output_arrow_schema, &error), 
error);
@@ -393,6 +390,20 @@ Result<ArrowArray> ProjectBatch(ArrowArray* input_batch,
   ICEBERG_PRECHECK(input_batch != nullptr, "input_batch must not be null");
   internal::ArrowArrayGuard input_batch_guard(input_batch);
 
+  ArrowArrayView input_view;
+  ArrowError error;
+  ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
+      ArrowArrayViewInitFromSchema(&input_view, 
&projection.input_arrow_schema(), &error),
+      error);
+  internal::ArrowArrayViewGuard input_view_guard(&input_view);
+  ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
+      ArrowArrayViewSetArray(&input_view, input_batch, &error), error);
+  if (projection.validate_batch()) {
+    ICEBERG_NANOARROW_RETURN_UNEXPECTED_WITH_ERROR(
+        ArrowArrayViewValidate(&input_view, NANOARROW_VALIDATION_LEVEL_FULL, 
&error),
+        error);
+  }
+
   auto project_batch_function = projection.project_batch_function();
   if (project_batch_function != nullptr) {
     // ProjectBatch owns input_batch. Arrow-backed implementations import it 
and clear
@@ -402,7 +413,7 @@ Result<ArrowArray> ProjectBatch(ArrowArray* input_batch,
 
   return ProjectBatchNanoarrow(projection.input_arrow_schema(), *input_batch, 
row_indices,
                                projection.output_arrow_schema(),
-                               projection.selected_field_indices());
+                               projection.selected_field_indices(), 
input_view);
 }
 
 namespace {
diff --git a/src/iceberg/arrow_c_data_util_internal.h 
b/src/iceberg/arrow_c_data_util_internal.h
index 899695b6..c5d20397 100644
--- a/src/iceberg/arrow_c_data_util_internal.h
+++ b/src/iceberg/arrow_c_data_util_internal.h
@@ -68,13 +68,16 @@ class ICEBERG_EXPORT ProjectionContext {
   /// \param project_batch_function Optional implementation returned by
   ///   ProjectionContext::ResolveProjectBatchFunction, or nullptr to use the 
nanoarrow
   ///   path.
+  /// \param validate_batch Whether to perform full content validation. 
Disable only for
+  /// trusted input.
   /// \note It validates that output_schema selects or reorders complete 
top-level fields
   /// by field id. Nested pruning and type changes are rejected. The 
input_schema and
   /// output_schema passed to Make must outlive the context. ProjectBatch may 
lazily
   /// initialize backend cache; do not share one context across concurrent 
calls.
   static Result<ProjectionContext> Make(const Schema& input_schema,
                                         const Schema& output_schema,
-                                        ProjectBatchFunction 
project_batch_function);
+                                        ProjectBatchFunction 
project_batch_function,
+                                        bool validate_batch = true);
 
   ProjectionContext(ProjectionContext&&) noexcept;
   ProjectionContext& operator=(ProjectionContext&&) noexcept;
@@ -97,6 +100,8 @@ class ICEBERG_EXPORT ProjectionContext {
 
   ProjectBatchState& project_batch_state();
 
+  bool validate_batch() const;
+
  private:
   ProjectionContext() = default;
 
@@ -109,6 +114,7 @@ class ICEBERG_EXPORT ProjectionContext {
   ArrowSchema output_arrow_schema_{};
   ProjectBatchFunction project_batch_function_ = nullptr;
   ProjectBatchState project_batch_state_;
+  bool validate_batch_ = true;
 };
 
 /// \brief Concept for sources that can be wrapped as ArrowArrayStreams.
@@ -228,10 +234,11 @@ ICEBERG_EXPORT Result<ArrowArrayStream> 
MakeArrowArrayStream(
 /// \brief Project selected rows from a batch into complete top-level fields.
 ///
 /// `input_batch` is consumed by this function. If the projection carries a 
registered
-/// implementation, the call is delegated to it; otherwise the built-in 
nanoarrow
-/// implementation is used. The projection must have been created for the 
stable schema
-/// pair that describes the input batch and requested output. This function 
does not
-/// revalidate schema compatibility on each batch.
+/// implementation, the call is delegated to it after validation; otherwise 
the built-in
+/// nanoarrow implementation is used. The projection must have been created 
for the stable
+/// schema pair that describes the input batch and requested output. This 
function does
+/// not revalidate schema compatibility on each batch. Input arrays are 
validated
+/// according to the context policy before either backend runs.
 ///
 /// \param input_batch Owned Arrow C Data batch to project.
 /// \param row_indices Zero-based row positions to copy from `input_batch`.
diff --git a/src/iceberg/data/file_scan_task_reader.cc 
b/src/iceberg/data/file_scan_task_reader.cc
index bf23c5c3..8d3c7328 100644
--- a/src/iceberg/data/file_scan_task_reader.cc
+++ b/src/iceberg/data/file_scan_task_reader.cc
@@ -213,9 +213,11 @@ class FileScanTaskReader::Impl {
     auto required_schema = delete_filter->RequiredSchema();
     auto project_batch_function = 
ProjectionContext::ResolveProjectBatchFunction();
     // ProjectionContext borrows schemas that are kept in 
MergeOnReadStreamSource.
-    ICEBERG_ASSIGN_OR_RAISE(auto projection_context,
-                            ProjectionContext::Make(*required_schema, 
*projected_schema_,
-                                                    project_batch_function));
+    ICEBERG_ASSIGN_OR_RAISE(
+        auto projection_context,
+        ProjectionContext::Make(*required_schema, *projected_schema_,
+                                project_batch_function,
+                                
properties_.Get(ReaderProperties::kArrowValidateBatch)));
 
     auto options = MakeReaderOptions(task, io_, required_schema, filter, 
case_sensitive_,
                                      name_mapping_, properties_, 
data_file->first_row_id,
diff --git a/src/iceberg/file_reader.h b/src/iceberg/file_reader.h
index 13e8b3ad..eded9191 100644
--- a/src/iceberg/file_reader.h
+++ b/src/iceberg/file_reader.h
@@ -80,6 +80,9 @@ class ICEBERG_EXPORT ReaderProperties : public 
ConfigBase<ReaderProperties> {
   /// Only the Parquet reader honors this option; other readers ignore it.
   /// Default: false (use 32-bit offset list).
   inline static Entry<bool> kArrowUseLargeList{"read.arrow.use-large-list", 
false};
+  /// \brief Validate Arrow batches before merge-on-read projection.
+  /// Default: true. Disable only when the reader is trusted to produce valid 
arrays.
+  inline static Entry<bool> kArrowValidateBatch{"read.arrow.validate-batch", 
true};
   /// \brief Use footer statistics to prune Parquet row groups.
   inline static Entry<bool> kParquetRowGroupFilter{
       "read.parquet.row-group-filter.enabled", true};
diff --git a/src/iceberg/test/arrow_c_data_util_test.cc 
b/src/iceberg/test/arrow_c_data_util_test.cc
index 35bb30fd..a21f88c4 100644
--- a/src/iceberg/test/arrow_c_data_util_test.cc
+++ b/src/iceberg/test/arrow_c_data_util_test.cc
@@ -20,6 +20,7 @@
 /// \file arrow_c_data_util_test.cc
 /// Verifies ProjectBatch behavior across registered implementations.
 
+#include <array>
 #include <cstdint>
 #include <memory>
 #include <string>
@@ -76,15 +77,15 @@ ProjectionContext::ProjectBatchFunction 
ArrowComputeFunction() {
 std::shared_ptr<::arrow::RecordBatch> RunProjectBatch(
     const ::arrow::RecordBatch& batch, const std::vector<int32_t>& 
alive_indices,
     const Schema& required_schema, const Schema& projected_schema,
-    ProjectionContext::ProjectBatchFunction project_batch_function) {
+    ProjectionContext::ProjectBatchFunction project_batch_function, bool 
validate_batch) {
   ArrowSchema c_schema;
   ArrowArray c_array;
   EXPECT_TRUE(::arrow::ExportRecordBatch(batch, &c_array, &c_schema).ok());
   ArrowSchemaGuard schema_guard(&c_schema);
   ArrowArrayGuard array_guard(&c_array);
 
-  auto projection =
-      ProjectionContext::Make(required_schema, projected_schema, 
project_batch_function);
+  auto projection = ProjectionContext::Make(required_schema, projected_schema,
+                                            project_batch_function, 
validate_batch);
   EXPECT_THAT(projection, IsOk());
 
   auto result = ProjectBatch(&c_array, alive_indices, projection.value());
@@ -103,22 +104,15 @@ void ExpectProjectBatch(const ::arrow::RecordBatch& batch,
                         const Schema& required_schema, const Schema& 
projected_schema,
                         std::string_view expected_json) {
   auto expected = MakeBatch(projected_schema, expected_json);
-  auto nanoarrow =
-      RunProjectBatch(batch, alive_indices, required_schema, projected_schema, 
nullptr);
-  auto arrow_compute = RunProjectBatch(batch, alive_indices, required_schema,
-                                       projected_schema, 
ArrowComputeFunction());
-
-  EXPECT_TRUE(nanoarrow->Equals(*expected)) << "nanoarrow:\n"
-                                            << nanoarrow->ToString() << 
"expected:\n"
-                                            << expected->ToString();
-  EXPECT_TRUE(arrow_compute->Equals(*expected))
-      << "arrow_compute:\n"
-      << arrow_compute->ToString() << "expected:\n"
-      << expected->ToString();
-  EXPECT_TRUE(nanoarrow->Equals(*arrow_compute))
-      << "nanoarrow:\n"
-      << nanoarrow->ToString() << "arrow_compute:\n"
-      << arrow_compute->ToString();
+  for (bool validate_batch : {true, false}) {
+    auto nanoarrow = RunProjectBatch(batch, alive_indices, required_schema,
+                                     projected_schema, nullptr, 
validate_batch);
+    auto arrow_compute =
+        RunProjectBatch(batch, alive_indices, required_schema, 
projected_schema,
+                        ArrowComputeFunction(), validate_batch);
+    EXPECT_TRUE(nanoarrow->Equals(*expected)) << nanoarrow->ToString();
+    EXPECT_TRUE(arrow_compute->Equals(*expected)) << arrow_compute->ToString();
+  }
 }
 
 std::shared_ptr<Schema> MakeFullSchema() {
@@ -231,6 +225,87 @@ TEST(ProjectBatchTest, ProjectionRejectsNestedPruning) {
   EXPECT_THAT(projection, IsError(ErrorKind::kInvalidArgument));
 }
 
+TEST(ProjectBatchTest, ValidateBatchControlsNonMonotonicStringOffsets) {
+  for (bool validate_batch : {true, false}) {
+    for (auto function :
+         {ProjectionContext::ProjectBatchFunction{nullptr}, 
ArrowComputeFunction()}) {
+      auto schema = std::make_shared<Schema>(
+          std::vector<SchemaField>{SchemaField::MakeRequired(1, "value", 
string())});
+
+      // DEFAULT accepts the first/last offsets; FULL rejects the intermediate 
decrease.
+      ArrowArray root;
+      ASSERT_EQ(ArrowArrayInitFromType(&root, NANOARROW_TYPE_STRUCT), 
NANOARROW_OK);
+      ASSERT_EQ(ArrowArrayAllocateChildren(&root, 1), NANOARROW_OK);
+
+      ArrowArray* value_array = root.children[0];
+      ASSERT_EQ(ArrowArrayInitFromType(value_array, NANOARROW_TYPE_STRING), 
NANOARROW_OK);
+      const std::array<int32_t, 3> raw_offsets = {0, 5, 2};
+      ASSERT_EQ(ArrowBufferAppend(ArrowArrayBuffer(value_array, 1), 
raw_offsets.data(),
+                                  raw_offsets.size() * sizeof(int32_t)),
+                NANOARROW_OK);
+      ASSERT_EQ(ArrowBufferAppend(ArrowArrayBuffer(value_array, 2), "ab", 2),
+                NANOARROW_OK);
+      value_array->length = 2;
+      value_array->null_count = 0;
+      ASSERT_EQ(ArrowArrayFinishBuildingDefault(value_array, nullptr), 
NANOARROW_OK);
+
+      root.length = 2;
+      root.null_count = 0;
+      ASSERT_EQ(ArrowArrayFinishBuildingDefault(&root, nullptr), NANOARROW_OK);
+
+      auto projection =
+          ProjectionContext::Make(*schema, *schema, function, validate_batch);
+      ASSERT_THAT(projection, IsOk());
+
+      // Empty selection skips the content check without reading corrupt data.
+      auto result = ProjectBatch(&root, /*row_indices=*/{}, 
projection.value());
+      if (validate_batch) {
+        EXPECT_THAT(result, IsError(ErrorKind::kInvalidArrowData));
+        EXPECT_THAT(result, HasErrorMessage("element size >= 0"));
+      } else {
+        EXPECT_THAT(result, IsOk());
+      }
+      if (result.has_value()) {
+        ArrowArrayGuard guard(&result.value());
+        EXPECT_EQ(result->length, 0);
+      }
+      EXPECT_EQ(root.release, nullptr);
+    }
+  }
+}
+
+TEST(ProjectBatchTest, SkippedContentValidationStillRejectsInvalidStructure) {
+  auto schema = MakeFullSchema();
+  auto batch = MakeBatch(*schema, R"([[1,"a",1.0]])");
+  for (auto function :
+       {ProjectionContext::ProjectBatchFunction{nullptr}, 
ArrowComputeFunction()}) {
+    ArrowArray array;
+    ASSERT_TRUE(::arrow::ExportRecordBatch(*batch, &array).ok());
+    array.length = -1;
+    ICEBERG_UNWRAP_OR_FAIL(
+        auto projection,
+        ProjectionContext::Make(*schema, *schema, function, 
/*validate_batch=*/false));
+    auto result = ProjectBatch(&array, {}, projection);
+    EXPECT_THAT(result, IsError(ErrorKind::kInvalidArrowData));
+    EXPECT_THAT(result, HasErrorMessage("length"));
+    EXPECT_EQ(array.release, nullptr);
+  }
+}
+
+TEST(ProjectBatchTest, MovePreservesValidateBatch) {
+  auto schema = MakeFullSchema();
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto without_validation,
+      ProjectionContext::Make(*schema, *schema, nullptr, 
/*validate_batch=*/false));
+  auto moved = std::move(without_validation);
+  EXPECT_FALSE(moved.validate_batch());
+  ICEBERG_UNWRAP_OR_FAIL(auto validation,
+                         ProjectionContext::Make(*schema, *schema, nullptr));
+  EXPECT_TRUE(validation.validate_batch());
+  validation = std::move(moved);
+  EXPECT_FALSE(validation.validate_batch());
+}
+
 TEST(WriteProjectionTest, IdenticalSchemasTransferBatchWithoutConversion) {
   auto input_schema = MakeFullSchema();
   auto write_schema = MakeFullSchema();
diff --git a/src/iceberg/test/file_scan_task_reader_test.cc 
b/src/iceberg/test/file_scan_task_reader_test.cc
index 8b0d79d1..596ec488 100644
--- a/src/iceberg/test/file_scan_task_reader_test.cc
+++ b/src/iceberg/test/file_scan_task_reader_test.cc
@@ -671,6 +671,49 @@ TEST_F(FileScanTaskReaderTest, 
OpenWithDeletionVectorFiltersRows) {
   ASSERT_NO_FATAL_FAILURE(VerifyStream(&stream, R"([[1, "Foo"], [3, 
"Baz"]])"));
 }
 
+TEST_F(FileScanTaskReaderTest, ReadsArrowValidateBatchProperty) {
+  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 pos_delete, 
MakePositionDeleteFile(CreateNewTempFilePathWithSuffix(".parquet"),
+                                              {1}, data_file->file_path));
+  FileScanTask task(data_file, {pos_delete});
+
+  struct RestoreProjection {
+    ProjectionContext::ProjectBatchFunction function =
+        ProjectionContext::ResolveProjectBatchFunction();
+    ~RestoreProjection() { 
ProjectionContext::RegisterProjectBatchFunction(function); }
+  } restore;
+  ProjectionContext::RegisterProjectBatchFunction(
+      [](ArrowArray*, std::span<const int32_t>,
+         ProjectionContext& projection) -> Result<ArrowArray> {
+        return InvalidArgument("projection mode: {}",
+                               projection.validate_batch() ? "full" : "basic");
+      });
+
+  for (bool validate_batch : {true, false}) {
+    FileScanTaskReader::Options options{
+        .io = file_io_,
+        .table_schema = table_schema_,
+        .projected_schema = projected_schema_,
+    };
+    if (!validate_batch) {
+      options.properties[ReaderProperties::kArrowValidateBatch.key()] = 
"false";
+    }
+    ICEBERG_UNWRAP_OR_FAIL(auto reader, 
FileScanTaskReader::Make(std::move(options)));
+    ICEBERG_UNWRAP_OR_FAIL(auto stream, reader->Open(task));
+
+    ArrowArray array{};
+    EXPECT_NE(stream.get_next(&stream, &array), 0);
+    EXPECT_THAT(stream.get_last_error(&stream),
+                ::testing::HasSubstr(validate_batch ? "projection mode: full"
+                                                    : "projection mode: 
basic"));
+    stream.release(&stream);
+  }
+}
+
 TEST_F(FileScanTaskReaderTest, 
OpenWithEqualityDeletesAddsAndPrunesDeleteOnlyColumns) {
   ICEBERG_UNWRAP_OR_FAIL(
       auto data_file,

Reply via email to