You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/07/29 08:50:33 UTC

(iceberg-cpp) branch main updated: fix: guard equality delete bounds against missing fields (#857)

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 54423808 fix: guard equality delete bounds against missing fields (#857)
54423808 is described below

commit 54423808e51855612a7ac4e47d478710e3b085ce
Author: YangJie <ya...@baidu.com>
AuthorDate: Wed Jul 29 16:50:27 2026 +0800

    fix: guard equality delete bounds against missing fields (#857)
    
    ## What
    
    `internal::CanContainEqDeletesForFile` crashes with
    `std::bad_optional_access` when an equality delete file has lower/upper
    bounds for some of its equality fields but not others.
    `EqualityDeleteFile::LowerBound`/`UpperBound` return
    `Result<std::optional<...>>` (i.e. `std::expected<std::optional<...>,
    Error>`), and a field with no bound yields an engaged `expected`
    wrapping a disengaged `optional`. The guard checked
    `std::expected::has_value()`, which only reports the error state and
    does not detect the empty inner `optional`, so a missing bound slipped
    past and `->value()` threw at the range check.
    
    The throw is an uncaught exception escaping the `Result`-based call
    chain, so it terminates the caller instead of surfacing as an error. It
    is reachable during scan planning (`ManifestGroup` →
    `DeleteFileIndex::ForEntry`) and commit validation
    (`MergingSnapshotUpdate::ValidateNoNewDeletesForDataFiles` →
    `ForDataFile`). Equality delete files with bounds for only some fields
    are produced legitimately by per-column metrics modes
    (`counts`/`none`/`truncate`) and by cross-engine writers, so this is
    reachable in normal reader use.
    
    Fixes #856.
    
    ## How
    
    Unwrap the `expected` with `ICEBERG_ASSIGN_OR_RAISE` before the guard,
    so `has_value()` tests the inner `optional`. This matches the data-side
    guard already used a few lines above in the same function, and matches
    Java's `DeleteFileIndex.canContainEqDeletesForFile`, which guards all
    four bounds uniformly and assumes may-match on any missing bound (it
    never prunes on missing statistics).
    
    As a side effect this propagates a genuine bound-deserialization error
    upward instead of silently treating it as may-match. That is consistent
    with the data-side `Literal::Deserialize` calls in the same function,
    which already propagate via `ICEBERG_ASSIGN_OR_RAISE`.
    
    ## Testing
    
    Added `DeleteFileIndexTest.TestEqualityDeletePartialFieldBounds`: an
    equality delete over fields `{1, 2}` with bounds for field 1 only,
    evaluated against a data file that has bounds for both (field 1's range
    overlapping so evaluation reaches field 2). Verified fail-without
    (throws `std::bad_optional_access`) / pass-with (returns the delete file
    as may-match). `null_value_counts` is pinned to 0 for both fields so the
    null short-circuits cannot mask the range-check path if the schema
    fields later change nullability. Full `manifest_test` passes.
---
 src/iceberg/delete_file_index.cc           |  8 ++--
 src/iceberg/test/delete_file_index_test.cc | 65 ++++++++++++++++++++++++++++++
 2 files changed, 69 insertions(+), 4 deletions(-)

diff --git a/src/iceberg/delete_file_index.cc b/src/iceberg/delete_file_index.cc
index 9a8be06b..5f3f5174 100644
--- a/src/iceberg/delete_file_index.cc
+++ b/src/iceberg/delete_file_index.cc
@@ -145,8 +145,8 @@ Result<bool> CanContainEqDeletesForFile(const DataFile& data_file,
       continue;  // Missing bounds, assume may match
     }
 
-    auto delete_lower = delete_file.LowerBound(field_id);
-    auto delete_upper = delete_file.UpperBound(field_id);
+    ICEBERG_ASSIGN_OR_RAISE(auto delete_lower, delete_file.LowerBound(field_id));
+    ICEBERG_ASSIGN_OR_RAISE(auto delete_upper, delete_file.UpperBound(field_id));
     if (!delete_lower.has_value() || !delete_upper.has_value()) {
       continue;  // Missing bounds, assume may match
     }
@@ -158,8 +158,8 @@ Result<bool> CanContainEqDeletesForFile(const DataFile& data_file,
     ICEBERG_ASSIGN_OR_RAISE(auto data_upper,
                             Literal::Deserialize(data_upper_it->second, primitive_type));
 
-    if (!RangesOverlap(data_lower, data_upper, delete_lower->value().get(),
-                       delete_upper->value().get())) {
+    if (!RangesOverlap(data_lower, data_upper, delete_lower->get(),
+                       delete_upper->get())) {
       return false;  // Ranges don't overlap - cannot match
     }
   }
diff --git a/src/iceberg/test/delete_file_index_test.cc b/src/iceberg/test/delete_file_index_test.cc
index 0c8c8821..fea9b6a0 100644
--- a/src/iceberg/test/delete_file_index_test.cc
+++ b/src/iceberg/test/delete_file_index_test.cc
@@ -840,6 +840,71 @@ TEST_P(DeleteFileIndexTest, TestEqualityDeletesGroup) {
   }
 }
 
+// Regression test: an equality delete file that carries bounds for some equality
+// fields but not others must not crash. CanContainEqDeletesForFile guarded the
+// missing-bound case with std::expected::has_value() (which only reports the error
+// state), so a field whose bound was absent produced an engaged expected wrapping a
+// disengaged optional, and dereferencing it threw std::bad_optional_access. Such
+// files are produced legitimately by per-column metrics modes (counts/none/truncate)
+// and by cross-engine writers.
+TEST_P(DeleteFileIndexTest, TestEqualityDeletePartialFieldBounds) {
+  auto partition_a = PartitionValues({Literal::Int(0)});
+
+  auto serialize = [](const Literal& literal) {
+    auto result = literal.Serialize();
+    EXPECT_THAT(result, IsOk());
+    return result.value();
+  };
+
+  // Equality delete over fields {1 (int id), 2 (string data)} but with bounds only
+  // for field 1. Field 1's bounds must deserialize cleanly, otherwise
+  // ConvertBoundsIfNeeded fails first and masks the missing-bound path.
+  //
+  // null_value_counts is set to 0 for both fields so the null short-circuits in
+  // CanContainEqDeletesForFile (ContainsNull/AllNull) never fire, which keeps the
+  // repro alive even if the SetUp schema fields are later changed to optional: with a
+  // zero count ContainsNull returns false regardless of nullability, so evaluation
+  // always reaches the range check that dereferences the missing field-2 bound.
+  auto eq_delete = std::make_shared<DataFile>(DataFile{
+      .content = DataFile::Content::kEqualityDeletes,
+      .file_path = "/path/to/eq-delete-partial-bounds.parquet",
+      .file_format = FileFormatType::kParquet,
+      .partition = partition_a,
+      .record_count = 10,
+      .file_size_in_bytes = 100,
+      .null_value_counts = {{1, 0}, {2, 0}},
+      .lower_bounds = {{1, serialize(Literal::Int(0))}},
+      .upper_bounds = {{1, serialize(Literal::Int(100))}},
+      .equality_ids = {1, 2},
+      .partition_spec_id = partitioned_spec_->spec_id(),
+  });
+
+  // Data file with full bounds for both equality fields. Field 1's range overlaps the
+  // delete's, so evaluation proceeds to field 2, which lacks a delete-side bound.
+  auto data_file = std::make_shared<DataFile>(DataFile{
+      .file_path = "/path/to/data-partial-bounds.parquet",
+      .file_format = FileFormatType::kParquet,
+      .partition = partition_a,
+      .record_count = 100,
+      .file_size_in_bytes = 1000,
+      .null_value_counts = {{1, 0}, {2, 0}},
+      .lower_bounds = {{1, serialize(Literal::Int(0))},
+                       {2, serialize(Literal::String("a"))}},
+      .upper_bounds = {{1, serialize(Literal::Int(100))},
+                       {2, serialize(Literal::String("z"))}},
+      .partition_spec_id = partitioned_spec_->spec_id(),
+  });
+
+  internal::EqualityDeletes group(*schema_);
+  auto entry = MakeDeleteEntry(/*snapshot_id=*/1000L, /*sequence_number=*/1, eq_delete);
+  EXPECT_THAT(group.Add(std::move(entry)), IsOk());
+
+  // Must not throw. With a missing field-2 bound the delete may still match, so it is
+  // returned rather than pruned.
+  ICEBERG_UNWRAP_OR_FAIL(auto filtered, group.Filter(/*seq=*/0, *data_file));
+  EXPECT_EQ(filtered.size(), 1);
+}
+
 TEST_P(DeleteFileIndexTest, TestMixDeleteFilesAndDVs) {
   auto version = GetParam();
   if (version < 3) {