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 7e81fa2d test: complete v3 row lineage test coverage (#869)
7e81fa2d is described below

commit 7e81fa2d96aeff0b9b19d78d9218739db2bb2947
Author: Gang Wu <[email protected]>
AuthorDate: Tue Aug 4 23:18:31 2026 +0800

    test: complete v3 row lineage test coverage (#869)
    
    - enable RewriteFiles V3 coverage and verify DV cleanup
    - cover V2-to-V3 assignment and incremental/changelog lineage
    - verify delete-aware readers preserve lineage
---
 src/iceberg/test/file_scan_task_reader_test.cc     |  89 +++++++++++-
 src/iceberg/test/incremental_append_scan_test.cc   |  52 +++++++
 .../test/incremental_changelog_scan_test.cc        |  98 ++++++++++++++
 src/iceberg/test/merging_snapshot_update_test.cc   |  99 ++++++++++++++
 src/iceberg/test/rewrite_files_test.cc             | 149 ++++++++++++++++++---
 5 files changed, 459 insertions(+), 28 deletions(-)

diff --git a/src/iceberg/test/file_scan_task_reader_test.cc 
b/src/iceberg/test/file_scan_task_reader_test.cc
index 2c2381c4..e8f49f10 100644
--- a/src/iceberg/test/file_scan_task_reader_test.cc
+++ b/src/iceberg/test/file_scan_task_reader_test.cc
@@ -113,6 +113,14 @@ class FileScanTaskReaderTest : public TempFileTestBase {
         table_schema_->schema_id());
   }
 
+  std::shared_ptr<Schema> RowLineageProjection() const {
+    return std::make_shared<Schema>(
+        std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int32()),
+                                 MetadataColumns::kRowId,
+                                 MetadataColumns::kLastUpdatedSequenceNumber},
+        table_schema_->schema_id());
+  }
+
   Result<ExportedBatch> MakeBatch(const Schema& schema,
                                   const std::string& json_data) const {
     ICEBERG_ASSIGN_OR_RAISE(auto arrow_schema, MakeArrowSchema(schema));
@@ -409,17 +417,12 @@ TEST_F(FileScanTaskReaderTest, 
ReadLastUpdatedFromDataSeq) {
   data_file->first_row_id = 100L;
   data_file->data_sequence_number = 5L;
   FileScanTask task(data_file);
-  auto projected_schema = std::make_shared<Schema>(
-      std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int32()),
-                               MetadataColumns::kRowId,
-                               MetadataColumns::kLastUpdatedSequenceNumber},
-      table_schema_->schema_id());
 
   FileScanTaskReader::Options options{
       .io = file_io_,
       .table_schema = table_schema_,
       .schemas = {table_schema_},
-      .projected_schema = projected_schema,
+      .projected_schema = RowLineageProjection(),
   };
   ICEBERG_UNWRAP_OR_FAIL(auto reader, 
FileScanTaskReader::Make(std::move(options)));
   auto stream_result = reader->Open(task);
@@ -504,6 +507,80 @@ TEST_F(FileScanTaskReaderTest, 
OpenWithEqualityDeletesAddsAndPrunesDeleteOnlyCol
   ASSERT_NO_FATAL_FAILURE(VerifyStream(&stream, R"([[1, "Foo"], [3, 
"Baz"]])"));
 }
 
+TEST_F(FileScanTaskReaderTest, PositionDeletesPreserveRowLineage) {
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto data_file,
+      MakeDataFile(table_schema_,
+                   R"([[1, "Foo", "blue"], [2, "Bar", "red"], [3, "Baz", 
"green"]])"));
+  data_file->first_row_id = 100;
+  data_file->data_sequence_number = 5;
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto pos_delete, 
MakePositionDeleteFile(CreateNewTempFilePathWithSuffix(".parquet"),
+                                              {1}, data_file->file_path));
+  FileScanTask task(data_file, {pos_delete});
+
+  FileScanTaskReader::Options options{
+      .io = file_io_,
+      .table_schema = table_schema_,
+      .schemas = {table_schema_},
+      .projected_schema = RowLineageProjection(),
+  };
+  ICEBERG_UNWRAP_OR_FAIL(auto reader, 
FileScanTaskReader::Make(std::move(options)));
+  ICEBERG_UNWRAP_OR_FAIL(auto stream, reader->Open(task));
+
+  ASSERT_NO_FATAL_FAILURE(VerifyStream(&stream, R"([[1, 100, 5], [3, 102, 
5]])"));
+}
+
+TEST_F(FileScanTaskReaderTest, DeletionVectorDeletesPreserveRowLineage) {
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto data_file,
+      MakeDataFile(table_schema_,
+                   R"([[1, "Foo", "blue"], [2, "Bar", "red"], [3, "Baz", 
"green"]])"));
+  data_file->first_row_id = 100;
+  data_file->data_sequence_number = 5;
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto deletion_vector,
+      MakeDeletionVectorFile(CreateNewTempFilePathWithSuffix(".puffin"), {1},
+                             data_file->file_path));
+  FileScanTask task(data_file, {deletion_vector});
+
+  FileScanTaskReader::Options options{
+      .io = file_io_,
+      .table_schema = table_schema_,
+      .schemas = {table_schema_},
+      .projected_schema = RowLineageProjection(),
+  };
+  ICEBERG_UNWRAP_OR_FAIL(auto reader, 
FileScanTaskReader::Make(std::move(options)));
+  ICEBERG_UNWRAP_OR_FAIL(auto stream, reader->Open(task));
+
+  ASSERT_NO_FATAL_FAILURE(VerifyStream(&stream, R"([[1, 100, 5], [3, 102, 
5]])"));
+}
+
+TEST_F(FileScanTaskReaderTest, EqualityDeletesPreserveRowLineage) {
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto data_file,
+      MakeDataFile(table_schema_,
+                   R"([[1, "Foo", "blue"], [2, "Bar", "red"], [3, "Baz", 
"green"]])"));
+  data_file->first_row_id = 100;
+  data_file->data_sequence_number = 5;
+  ICEBERG_UNWRAP_OR_FAIL(
+      auto equality_delete,
+      MakeEqualityDeleteFile(CreateNewTempFilePathWithSuffix(".parquet"), 
table_schema_,
+                             R"([[0, "unused", "red"]])", {3}));
+  FileScanTask task(data_file, {equality_delete});
+
+  FileScanTaskReader::Options options{
+      .io = file_io_,
+      .table_schema = table_schema_,
+      .schemas = {table_schema_},
+      .projected_schema = RowLineageProjection(),
+  };
+  ICEBERG_UNWRAP_OR_FAIL(auto reader, 
FileScanTaskReader::Make(std::move(options)));
+  ICEBERG_UNWRAP_OR_FAIL(auto stream, reader->Open(task));
+
+  ASSERT_NO_FATAL_FAILURE(VerifyStream(&stream, R"([[1, 100, 5], [3, 102, 
5]])"));
+}
+
 TEST_F(FileScanTaskReaderTest, 
OpenWithEqualityDeletesKeepsInputBatchWhenAllRowsAlive) {
   ICEBERG_UNWRAP_OR_FAIL(
       auto data_file,
diff --git a/src/iceberg/test/incremental_append_scan_test.cc 
b/src/iceberg/test/incremental_append_scan_test.cc
index 044942ad..194b5115 100644
--- a/src/iceberg/test/incremental_append_scan_test.cc
+++ b/src/iceberg/test/incremental_append_scan_test.cc
@@ -20,6 +20,7 @@
 #include <memory>
 #include <optional>
 #include <string>
+#include <unordered_map>
 #include <vector>
 
 #include <gmock/gmock.h>
@@ -588,6 +589,57 @@ TEST_P(IncrementalAppendScanTest, MultipleRootSnapshots) {
   }
 }
 
+TEST_P(IncrementalAppendScanTest, PlanRowLineage) {
+  if (GetParam() < 3) {
+    GTEST_SKIP() << "Row lineage is only assigned in v3 manifests";
+  }
+
+  auto snapshot_a =
+      MakeAppendSnapshot(3, 1000L, std::nullopt, 5L, 
{"/path/to/file_a.parquet"});
+  snapshot_a->first_row_id = 0;
+  snapshot_a->added_rows = 1;
+
+  auto file_b = MakeDataFile("/path/to/file_b.parquet");
+  auto entry_b = MakeEntry(ManifestStatus::kAdded, 2000L, 7L, file_b);
+  auto manifest_b = WriteDataManifest(3, 2000L, {std::move(entry_b)});
+  manifest_b.first_row_id = 1;
+  auto manifest_list_b = WriteManifestList(3, 2000L, 1000L, 7L, {manifest_b});
+  auto snapshot_b = std::make_shared<Snapshot>(Snapshot{
+      .snapshot_id = 2000L,
+      .parent_snapshot_id = 1000L,
+      .sequence_number = 7L,
+      .timestamp_ms = TimePointMsFromUnixMs(1609459200000L + 2000),
+      .manifest_list = manifest_list_b,
+      .summary = {{"operation", "append"}},
+      .schema_id = schema_->schema_id(),
+      .first_row_id = 1L,
+      .added_rows = 1L,
+  });
+  auto metadata = MakeTableMetadata(
+      {snapshot_a, snapshot_b}, 2000L,
+      {{"main", std::make_shared<SnapshotRef>(SnapshotRef{
+                    .snapshot_id = 2000L, .retention = 
SnapshotRef::Branch{}})}});
+  metadata->next_row_id = 2;
+
+  ICEBERG_UNWRAP_OR_FAIL(auto builder, 
MakeScanBuilder<IncrementalAppendScan>(metadata));
+  builder->FromSnapshot(1000L, /*inclusive=*/true).ToSnapshot(2000L);
+  ICEBERG_UNWRAP_OR_FAIL(auto scan, builder->Build());
+  ICEBERG_UNWRAP_OR_FAIL(auto tasks, scan->PlanFiles());
+  ASSERT_EQ(tasks.size(), 2);
+
+  std::unordered_map<std::string, std::shared_ptr<FileScanTask>> tasks_by_path;
+  for (const auto& task : tasks) {
+    tasks_by_path.emplace(task->data_file()->file_path, task);
+  }
+  ASSERT_EQ(tasks_by_path.size(), 2);
+  
EXPECT_EQ(tasks_by_path.at("/path/to/file_a.parquet")->data_file()->first_row_id,
 0);
+  EXPECT_EQ(
+      
tasks_by_path.at("/path/to/file_a.parquet")->data_file()->data_sequence_number, 
5);
+  
EXPECT_EQ(tasks_by_path.at("/path/to/file_b.parquet")->data_file()->first_row_id,
 1);
+  EXPECT_EQ(
+      
tasks_by_path.at("/path/to/file_b.parquet")->data_file()->data_sequence_number, 
7);
+}
+
 INSTANTIATE_TEST_SUITE_P(IncrementalAppendScanVersions, 
IncrementalAppendScanTest,
                          testing::Values(1, 2, 3));
 
diff --git a/src/iceberg/test/incremental_changelog_scan_test.cc 
b/src/iceberg/test/incremental_changelog_scan_test.cc
index 62f6d4fe..667f439b 100644
--- a/src/iceberg/test/incremental_changelog_scan_test.cc
+++ b/src/iceberg/test/incremental_changelog_scan_test.cc
@@ -454,6 +454,104 @@ TEST_P(IncrementalChangelogScanTest, 
ManifestRewritesAreIgnored) {
   EXPECT_EQ(insert_t3->data_file()->file_path, "/path/to/file_c.parquet");
 }
 
+TEST_P(IncrementalChangelogScanTest, PlanAddedRowLineage) {
+  if (GetParam() < 3) {
+    GTEST_SKIP() << "Row lineage is only assigned in v3 manifests";
+  }
+
+  auto snapshot_a =
+      MakeAppendSnapshot(3, 1000L, std::nullopt, 5L, 
{"/path/to/file_a.parquet"});
+  snapshot_a->first_row_id = 0;
+  snapshot_a->added_rows = 1;
+
+  auto file_b = MakeDataFile("/path/to/file_b.parquet");
+  auto entry_b = MakeEntry(ManifestStatus::kAdded, 2000L, 7L, file_b);
+  auto manifest_b = WriteDataManifest(3, 2000L, {std::move(entry_b)});
+  manifest_b.first_row_id = 1;
+  auto manifest_list_b = WriteManifestList(3, 2000L, 1000L, 7L, {manifest_b});
+  auto snapshot_b = std::make_shared<Snapshot>(Snapshot{
+      .snapshot_id = 2000L,
+      .parent_snapshot_id = 1000L,
+      .sequence_number = 7L,
+      .timestamp_ms = TimePointMsFromUnixMs(1609459200000L + 2000),
+      .manifest_list = manifest_list_b,
+      .summary = {{"operation", "append"}},
+      .schema_id = schema_->schema_id(),
+      .first_row_id = 1L,
+      .added_rows = 1L,
+  });
+  auto metadata = MakeTableMetadata(
+      {snapshot_a, snapshot_b}, 2000L,
+      {{"main", std::make_shared<SnapshotRef>(SnapshotRef{
+                    .snapshot_id = 2000L, .retention = 
SnapshotRef::Branch{}})}});
+  metadata->next_row_id = 2;
+
+  ICEBERG_UNWRAP_OR_FAIL(auto builder,
+                         MakeScanBuilder<IncrementalChangelogScan>(metadata));
+  builder->FromSnapshot(1000L, /*inclusive=*/true).ToSnapshot(2000L);
+  ICEBERG_UNWRAP_OR_FAIL(auto scan, builder->Build());
+  ICEBERG_UNWRAP_OR_FAIL(auto tasks, scan->PlanFiles());
+  ASSERT_EQ(tasks.size(), 2);
+  SortTasks(tasks);
+
+  auto added_a = std::dynamic_pointer_cast<AddedRowsScanTask>(tasks[0]);
+  ASSERT_NE(added_a, nullptr);
+  EXPECT_EQ(added_a->commit_snapshot_id(), 1000L);
+  EXPECT_EQ(added_a->data_file()->first_row_id, 0);
+  EXPECT_EQ(added_a->data_file()->data_sequence_number, 5);
+
+  auto added_b = std::dynamic_pointer_cast<AddedRowsScanTask>(tasks[1]);
+  ASSERT_NE(added_b, nullptr);
+  EXPECT_EQ(added_b->commit_snapshot_id(), 2000L);
+  EXPECT_EQ(added_b->data_file()->first_row_id, 1);
+  EXPECT_EQ(added_b->data_file()->data_sequence_number, 7);
+}
+
+TEST_P(IncrementalChangelogScanTest, PlanDeletedRowLineage) {
+  if (GetParam() < 3) {
+    GTEST_SKIP() << "Row lineage is only assigned in v3 manifests";
+  }
+
+  auto snapshot_a =
+      MakeAppendSnapshot(3, 1000L, std::nullopt, 5L, 
{"/path/to/file_a.parquet"});
+
+  auto deleted_file = MakeDataFile("/path/to/file_a.parquet");
+  deleted_file->first_row_id = 10;
+  auto deleted_entry = MakeEntry(ManifestStatus::kDeleted, 
/*snapshot_id=*/2000L,
+                                 /*sequence_number=*/5L, deleted_file);
+  auto delete_manifest =
+      WriteDataManifest(3, 2000L, {std::move(deleted_entry)}, 
unpartitioned_spec_);
+  auto manifest_list = WriteManifestList(3, 2000L, 1000L, 7L, 
{delete_manifest});
+  auto snapshot_b = std::make_shared<Snapshot>(Snapshot{
+      .snapshot_id = 2000L,
+      .parent_snapshot_id = 1000L,
+      .sequence_number = 7L,
+      .timestamp_ms = TimePointMsFromUnixMs(1609459200000L + 2000),
+      .manifest_list = manifest_list,
+      .summary = {{"operation", "overwrite"}},
+      .schema_id = schema_->schema_id(),
+      .first_row_id = 1L,
+      .added_rows = 0L,
+  });
+  auto metadata = MakeTableMetadata(
+      {snapshot_a, snapshot_b}, 2000L,
+      {{"main", std::make_shared<SnapshotRef>(SnapshotRef{
+                    .snapshot_id = 2000L, .retention = 
SnapshotRef::Branch{}})}});
+
+  ICEBERG_UNWRAP_OR_FAIL(auto builder,
+                         MakeScanBuilder<IncrementalChangelogScan>(metadata));
+  builder->FromSnapshot(1000L, /*inclusive=*/false).ToSnapshot(2000L);
+  ICEBERG_UNWRAP_OR_FAIL(auto scan, builder->Build());
+  ICEBERG_UNWRAP_OR_FAIL(auto tasks, scan->PlanFiles());
+  ASSERT_EQ(tasks.size(), 1);
+
+  auto deleted = std::dynamic_pointer_cast<DeletedDataFileScanTask>(tasks[0]);
+  ASSERT_NE(deleted, nullptr);
+  EXPECT_EQ(deleted->data_file()->first_row_id, 10);
+  EXPECT_EQ(deleted->data_file()->data_sequence_number, 5);
+  EXPECT_EQ(deleted->commit_snapshot_id(), 2000L);
+}
+
 TEST_P(IncrementalChangelogScanTest, DeleteFilesAreNotSupported) {
   auto version = GetParam();
   if (version < 2) {
diff --git a/src/iceberg/test/merging_snapshot_update_test.cc 
b/src/iceberg/test/merging_snapshot_update_test.cc
index 6684cf22..1c83e486 100644
--- a/src/iceberg/test/merging_snapshot_update_test.cc
+++ b/src/iceberg/test/merging_snapshot_update_test.cc
@@ -52,6 +52,7 @@
 #include "iceberg/transaction.h"
 #include "iceberg/update/fast_append.h"
 #include "iceberg/update/merge_append.h"
+#include "iceberg/update/row_delta.h"
 #include "iceberg/update/snapshot_manager.h"
 #include "iceberg/update/update_properties.h"
 #include "iceberg/util/macros.h"
@@ -273,6 +274,7 @@ class TestOverwriteUpdate : public MergingSnapshotUpdate {
   Status AddDelete(std::shared_ptr<DataFile> file, int64_t 
data_sequence_number) {
     return AddDeleteFile(std::move(file), data_sequence_number);
   }
+  Status AddFile(std::shared_ptr<DataFile> file) { return 
AddDataFile(std::move(file)); }
   Status RemoveDataFile(std::shared_ptr<DataFile> file) {
     return DeleteDataFile(std::move(file));
   }
@@ -294,6 +296,7 @@ class MergingSnapshotUpdateTest : public 
MinimalUpdateTestBase {
 
     file_a_ = MakeDataFile("/data/file_a.parquet", /*partition_x=*/1L);
     file_b_ = MakeDataFile("/data/file_b.parquet", /*partition_x=*/2L);
+    file_c_ = MakeDataFile("/data/file_c.parquet", /*partition_x=*/3L);
   }
 
   std::shared_ptr<DataFile> MakeDataFile(const std::string& path, int64_t 
partition_x) {
@@ -371,6 +374,28 @@ class MergingSnapshotUpdateTest : public 
MinimalUpdateTestBase {
     EXPECT_THAT(table_->Refresh(), IsOk());
   }
 
+  void CommitV2FilesBeforeUpgrade() {
+    ASSERT_EQ(table_->metadata()->format_version, 2);
+
+    ICEBERG_UNWRAP_OR_FAIL(auto append, table_->NewFastAppend());
+    append->AppendFile(file_a_).AppendFile(file_b_);
+    ASSERT_THAT(append->Commit(), IsOk());
+    ASSERT_THAT(table_->Refresh(), IsOk());
+
+    auto equality_delete =
+        MakeEqualityDeleteFile("/delete/upgrade_eq_delete.parquet", 1L);
+    ICEBERG_UNWRAP_OR_FAIL(auto delta, table_->NewRowDelta());
+    delta->AddDeletes(equality_delete);
+    ASSERT_THAT(delta->Commit(), IsOk());
+    ASSERT_THAT(table_->Refresh(), IsOk());
+
+    ICEBERG_UNWRAP_OR_FAIL(auto overwrite, NewOverwriteUpdate());
+    ASSERT_THAT(overwrite->RemoveDataFile(file_b_), IsOk());
+    ASSERT_THAT(overwrite->AddFile(file_c_), IsOk());
+    ASSERT_THAT(overwrite->Commit(), IsOk());
+    ASSERT_THAT(table_->Refresh(), IsOk());
+  }
+
   // Read all entries from a list of ManifestFiles.
   Result<std::vector<ManifestEntry>> ReadAllEntries(
       const std::vector<ManifestFile>& manifests, const TableMetadata& 
metadata) {
@@ -487,6 +512,7 @@ class MergingSnapshotUpdateTest : public 
MinimalUpdateTestBase {
   std::shared_ptr<Schema> schema_;
   std::shared_ptr<DataFile> file_a_;
   std::shared_ptr<DataFile> file_b_;
+  std::shared_ptr<DataFile> file_c_;
 };
 
 // -------------------------------------------------------------------------
@@ -658,6 +684,79 @@ TEST_F(MergingSnapshotUpdateTest, V3RetryRowIds) {
             std::make_optional(file_b_->record_count));
 }
 
+TEST_F(MergingSnapshotUpdateTest, V3UpgradeLeavesExistingRowsUnassigned) {
+  CommitV2FilesBeforeUpgrade();
+
+  const auto v2_snapshot_id = table_->metadata()->current_snapshot_id;
+  UpgradeTableToV3();
+
+  EXPECT_EQ(table_->metadata()->next_row_id, 0);
+  for (const auto& snapshot : table_->metadata()->snapshots) {
+    EXPECT_EQ(snapshot->first_row_id, std::nullopt);
+    EXPECT_EQ(snapshot->added_rows, std::nullopt);
+  }
+  ICEBERG_UNWRAP_OR_FAIL(auto current, table_->current_snapshot());
+  EXPECT_EQ(current->snapshot_id, v2_snapshot_id);
+  EXPECT_EQ(current->first_row_id, std::nullopt);
+  EXPECT_EQ(current->added_rows, std::nullopt);
+
+  SnapshotCache snapshot_cache(current.get());
+  ICEBERG_UNWRAP_OR_FAIL(auto data_manifest_range,
+                         snapshot_cache.DataManifests(file_io_));
+  std::vector<ManifestFile> data_manifests(data_manifest_range.begin(),
+                                           data_manifest_range.end());
+  ASSERT_FALSE(data_manifests.empty());
+  for (const auto& manifest : data_manifests) {
+    EXPECT_EQ(manifest.first_row_id, std::nullopt);
+  }
+
+  ICEBERG_UNWRAP_OR_FAIL(auto first_row_ids,
+                         DataFileFirstRowIds(current, *table_->metadata()));
+  EXPECT_EQ(first_row_ids.at(file_a_->file_path), std::nullopt);
+  EXPECT_EQ(first_row_ids.at(file_b_->file_path), std::nullopt);
+  EXPECT_EQ(first_row_ids.at(file_c_->file_path), std::nullopt);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto delete_manifest_range,
+                         snapshot_cache.DeleteManifests(file_io_));
+  ASSERT_FALSE(delete_manifest_range.empty());
+  for (const auto& manifest : delete_manifest_range) {
+    EXPECT_EQ(manifest.first_row_id, std::nullopt);
+  }
+}
+
+TEST_F(MergingSnapshotUpdateTest, 
V3FirstCommitAssignsExistingRowsAfterUpgrade) {
+  CommitV2FilesBeforeUpgrade();
+
+  UpgradeTableToV3();
+
+  ICEBERG_UNWRAP_OR_FAIL(auto append, table_->NewFastAppend());
+  ASSERT_THAT(append->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  const auto expected_rows = file_a_->record_count + file_c_->record_count;
+  ICEBERG_UNWRAP_OR_FAIL(auto assigned, table_->current_snapshot());
+  EXPECT_EQ(assigned->first_row_id, 0);
+  EXPECT_EQ(assigned->added_rows, expected_rows);
+  EXPECT_EQ(table_->metadata()->next_row_id, expected_rows);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto first_row_ids,
+                         DataFileFirstRowIds(assigned, *table_->metadata()));
+  ASSERT_TRUE(first_row_ids.at(file_a_->file_path).has_value());
+  ASSERT_TRUE(first_row_ids.at(file_c_->file_path).has_value());
+  EXPECT_EQ(first_row_ids.at(file_b_->file_path), std::nullopt);
+  EXPECT_NE(first_row_ids.at(file_a_->file_path), 
first_row_ids.at(file_c_->file_path));
+  EXPECT_THAT((std::vector<int64_t>{*first_row_ids.at(file_a_->file_path),
+                                    *first_row_ids.at(file_c_->file_path)}),
+              ::testing::UnorderedElementsAre(0, file_a_->record_count));
+
+  SnapshotCache snapshot_cache(assigned.get());
+  ICEBERG_UNWRAP_OR_FAIL(auto delete_manifests, 
snapshot_cache.DeleteManifests(file_io_));
+  ASSERT_FALSE(delete_manifests.empty());
+  for (const auto& manifest : delete_manifests) {
+    EXPECT_EQ(manifest.first_row_id, std::nullopt);
+  }
+}
+
 TEST_F(MergingSnapshotUpdateTest, CommitMultipleDataFiles) {
   ICEBERG_UNWRAP_OR_FAIL(auto op, NewMergeAppend());
   EXPECT_THAT(op->AddFile(file_a_), IsOk());
diff --git a/src/iceberg/test/rewrite_files_test.cc 
b/src/iceberg/test/rewrite_files_test.cc
index 7a825835..d6c3e7b4 100644
--- a/src/iceberg/test/rewrite_files_test.cc
+++ b/src/iceberg/test/rewrite_files_test.cc
@@ -23,6 +23,7 @@
 #include <cstdint>
 #include <memory>
 #include <string>
+#include <string_view>
 #include <vector>
 
 #include <arrow/filesystem/filesystem.h>
@@ -64,9 +65,10 @@ class RewriteFilesTest : public MinimalUpdateTestBase {
         MakeDataFile("/data/file_a_rewritten.parquet", /*partition_x=*/1L);
     rewritten_file_b_ =
         MakeDataFile("/data/file_b_rewritten.parquet", /*partition_x=*/2L);
-    delete_file_a_ = MakeDeleteFile("/data/delete_a.parquet", 
/*partition_x=*/1L);
-    rewritten_delete_file_a_ =
-        MakeDeleteFile("/data/delete_a_rewritten.parquet", /*partition_x=*/1L);
+    delete_file_a_ = MakePositionDeleteFile("/data/delete_a.parquet", 
/*partition_x=*/1L,
+                                            file_a_->file_path);
+    rewritten_delete_file_a_ = MakePositionDeleteFile(
+        "/data/delete_a_rewritten.parquet", /*partition_x=*/1L, 
file_a_->file_path);
     eq_delete_file_ =
         MakeEqualityDeleteFile("/data/eq_delete_a.parquet", 
/*partition_x=*/1L);
   }
@@ -83,18 +85,31 @@ class RewriteFilesTest : public MinimalUpdateTestBase {
     return f;
   }
 
-  std::shared_ptr<DataFile> MakeDeleteFile(const std::string& path, int64_t 
partition_x) {
-    auto f = MakeDataFile(path, partition_x);
-    f->content = DataFile::Content::kPositionDeletes;
-    return f;
+  std::shared_ptr<DataFile> MakePositionDeleteFile(
+      const std::string& path, int64_t partition_x,
+      const std::string& referenced_data_file) {
+    auto file = MakeDataFile(path, partition_x);
+    file->content = DataFile::Content::kPositionDeletes;
+    if (table_->metadata()->format_version >= 3) {
+      constexpr std::string_view kParquetSuffix = ".parquet";
+      if (file->file_path.ends_with(kParquetSuffix)) {
+        file->file_path.replace(file->file_path.size() - kParquetSuffix.size(),
+                                kParquetSuffix.size(), ".puffin");
+      }
+      file->file_format = FileFormatType::kPuffin;
+      file->referenced_data_file = referenced_data_file;
+      file->content_offset = 0;
+      file->content_size_in_bytes = 10;
+    }
+    return file;
   }
 
   std::shared_ptr<DataFile> MakeEqualityDeleteFile(const std::string& path,
                                                    int64_t partition_x) {
-    auto f = MakeDeleteFile(path, partition_x);
-    f->content = DataFile::Content::kEqualityDeletes;
-    f->equality_ids = {1};
-    return f;
+    auto file = MakeDataFile(path, partition_x);
+    file->content = DataFile::Content::kEqualityDeletes;
+    file->equality_ids = {1};
+    return file;
   }
 
   Result<std::shared_ptr<RewriteFiles>> NewRewriteFiles() {
@@ -311,6 +326,13 @@ TEST_P(RewriteFilesFormatVersionTest, 
RewriteDeleteFilesCopiesCallerFiles) {
   }
   CommitFileA();
 
+  if (format_version() >= 3) {
+    EXPECT_TRUE(delete_file_a_->IsDeletionVector());
+    EXPECT_EQ(delete_file_a_->referenced_data_file, file_a_->file_path);
+  } else {
+    EXPECT_FALSE(delete_file_a_->IsDeletionVector());
+  }
+
   {
     ICEBERG_UNWRAP_OR_FAIL(auto row_delta, table_->NewRowDelta());
     row_delta->AddDeletes(delete_file_a_);
@@ -544,9 +566,10 @@ TEST_P(RewriteFilesFormatVersionTest, DataSequenceNumber) {
     GTEST_SKIP() << "Requires format version >= 2";
   }
   CommitFileA();
+  const auto data_sequence_number = table_->metadata()->last_sequence_number;
 
   ICEBERG_UNWRAP_OR_FAIL(auto rw, NewRewriteFiles());
-  rw->SetDataSequenceNumber(5);
+  rw->SetDataSequenceNumber(data_sequence_number);
   rw->DeleteDataFile(file_a_);
   rw->AddDataFile(rewritten_file_a_);
   EXPECT_THAT(rw->Commit(), IsOk());
@@ -555,6 +578,23 @@ TEST_P(RewriteFilesFormatVersionTest, DataSequenceNumber) {
   ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
   EXPECT_EQ(snapshot->summary.at(SnapshotSummaryFields::kOperation),
             DataOperation::kReplace);
+
+  ICEBERG_UNWRAP_OR_FAIL(auto manifests, DataManifests(snapshot));
+  bool found_rewritten = false;
+  for (const auto& manifest : manifests) {
+    ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadAllEntries(std::span{&manifest, 
1}));
+    for (const auto& entry : entries) {
+      if (entry.data_file != nullptr &&
+          entry.data_file->file_path == rewritten_file_a_->file_path) {
+        found_rewritten = true;
+        EXPECT_EQ(entry.status, ManifestStatus::kAdded);
+        EXPECT_EQ(entry.sequence_number, data_sequence_number);
+        EXPECT_EQ(manifest.sequence_number, snapshot->sequence_number);
+      }
+    }
+  }
+  EXPECT_TRUE(found_rewritten) << "Rewritten data file should be present";
+  EXPECT_EQ(snapshot->sequence_number, 
table_->metadata()->last_sequence_number);
 }
 
 // Bulk rewrite with sequence number via the RewriteDataFiles convenience 
method.
@@ -773,8 +813,10 @@ TEST_P(RewriteFilesFormatVersionTest, 
ReplaceEqualityDeletesWithPositionDeletes)
             DataOperation::kReplace);
   EXPECT_EQ(
       
std::stoll(snapshot->summary.at(SnapshotSummaryFields::kRemovedEqDeleteFiles)), 
1);
-  
EXPECT_EQ(std::stoll(snapshot->summary.at(SnapshotSummaryFields::kAddedPosDeleteFiles)),
-            1);
+  const auto& added_delete_summary = format_version() >= 3
+                                         ? SnapshotSummaryFields::kAddedDVs
+                                         : 
SnapshotSummaryFields::kAddedPosDeleteFiles;
+  EXPECT_EQ(std::stoll(snapshot->summary.at(added_delete_summary)), 1);
 
   // Verify the delete manifest shows the eq delete as DELETED and pos delete 
as ADDED
   ICEBERG_UNWRAP_OR_FAIL(auto delete_manifests, DeleteManifests(snapshot));
@@ -792,7 +834,7 @@ TEST_P(RewriteFilesFormatVersionTest, 
ReplaceEqualityDeletesWithPositionDeletes)
     }
   }
   EXPECT_TRUE(found_deleted_eq) << "Equality delete should be marked DELETED";
-  EXPECT_TRUE(found_added_pos) << "Position delete should be marked ADDED";
+  EXPECT_TRUE(found_added_pos) << "Position delete or DV should be marked 
ADDED";
 }
 
 // Remove all deletes: create a data file and an associated equality delete,
@@ -987,16 +1029,79 @@ TEST_P(RewriteFilesFormatVersionTest, 
RecoverWhenRewriteBothDataAndDeleteFiles)
             1);
 }
 
+TEST_F(RewriteFilesTest, V3RewriteSuppressesCallerFirstRowId) {
+  ICEBERG_UNWRAP_OR_FAIL(auto props, table_->NewUpdateProperties());
+  props->Set(TableProperties::kFormatVersion.key(), "3");
+  ASSERT_THAT(props->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  CommitFileA();
+  ASSERT_EQ(table_->metadata()->next_row_id, file_a_->record_count);
+
+  rewritten_file_a_->first_row_id = 9999;
+  ICEBERG_UNWRAP_OR_FAIL(auto rewrite, NewRewriteFiles());
+  rewrite->RewriteDataFiles({file_a_}, {rewritten_file_a_}, 
/*sequence_number=*/1);
+  ASSERT_THAT(rewrite->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  ICEBERG_UNWRAP_OR_FAIL(auto manifests, DataManifests(snapshot));
+  ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadAllEntries(manifests));
+  auto rewritten_entry =
+      std::ranges::find_if(entries, [this](const ManifestEntry& entry) {
+        return entry.status == ManifestStatus::kAdded && entry.data_file != 
nullptr &&
+               entry.data_file->file_path == rewritten_file_a_->file_path;
+      });
+  ASSERT_NE(rewritten_entry, entries.end());
+  EXPECT_EQ(rewritten_entry->data_file->first_row_id, file_a_->record_count);
+  EXPECT_NE(rewritten_entry->data_file->first_row_id, 
rewritten_file_a_->first_row_id);
+  EXPECT_EQ(table_->metadata()->next_row_id,
+            file_a_->record_count + rewritten_file_a_->record_count);
+}
+
+TEST_F(RewriteFilesTest, RemovingDataFileAlsoRemovesDV) {
+  ICEBERG_UNWRAP_OR_FAIL(auto props, table_->NewUpdateProperties());
+  props->Set(TableProperties::kFormatVersion.key(), "3");
+  ASSERT_THAT(props->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  auto dv_a =
+      MakePositionDeleteFile("/data/dv_a.puffin", /*partition_x=*/1L, 
file_a_->file_path);
+  auto dv_b =
+      MakePositionDeleteFile("/data/dv_b.puffin", /*partition_x=*/2L, 
file_b_->file_path);
+  ASSERT_TRUE(dv_a->IsDeletionVector());
+  ASSERT_TRUE(dv_b->IsDeletionVector());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto delta, table_->NewRowDelta());
+  delta->AddRows(file_a_).AddRows(file_b_).AddDeletes(dv_a).AddDeletes(dv_b);
+  ASSERT_THAT(delta->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto base_snapshot, table_->current_snapshot());
+  ICEBERG_UNWRAP_OR_FAIL(auto rewrite, NewRewriteFiles());
+  rewrite->ValidateFromSnapshot(base_snapshot->snapshot_id);
+  rewrite->DeleteDataFile(file_a_);
+  ASSERT_THAT(rewrite->Commit(), IsOk());
+  ASSERT_THAT(table_->Refresh(), IsOk());
+
+  ICEBERG_UNWRAP_OR_FAIL(auto snapshot, table_->current_snapshot());
+  ICEBERG_UNWRAP_OR_FAIL(auto delete_manifests, DeleteManifests(snapshot));
+  ICEBERG_UNWRAP_OR_FAIL(auto entries, ReadAllEntries(delete_manifests));
+  ASSERT_EQ(entries.size(), 2);
+  EXPECT_TRUE(std::ranges::any_of(entries, [&dv_a](const ManifestEntry& entry) 
{
+    return entry.status == ManifestStatus::kDeleted && entry.data_file != 
nullptr &&
+           entry.data_file->file_path == dv_a->file_path;
+  }));
+  EXPECT_TRUE(std::ranges::any_of(entries, [&dv_b](const ManifestEntry& entry) 
{
+    return entry.status == ManifestStatus::kExisting && entry.data_file != 
nullptr &&
+           entry.data_file->file_path == dv_b->file_path;
+  }));
+}
+
 // ============================================================================
 // TODO(WZhuo): Tests blocked on missing infrastructure in iceberg-cpp.
 // ============================================================================
 //
-// TODO(RemovingDataFileAlsoRemovesDV):
-//   Blocked by: format v3 DV auto-cleanup not yet supported.
-//   Creates data+delete files via RowDelta (v3), rewrites with deleteFile.
-//   Verifies the DV for the removed data file is automatically cleaned up.
-//   Java guard: assumeThat(formatVersion).isGreaterThanOrEqualTo(3)
-//
 // TODO(DeleteWithDuplicateEntriesInManifest):
 //   Blocked by: cannot yet append the same file twice to create duplicate 
manifest
 //   entries. Appends FILE_A twice, then rewrites one copy. Verifies manifest 
entry
@@ -1004,6 +1109,6 @@ TEST_P(RewriteFilesFormatVersionTest, 
RecoverWhenRewriteBothDataAndDeleteFiles)
 //   Java guard: none (runs on all versions)
 
 INSTANTIATE_TEST_SUITE_P(FormatVersions, RewriteFilesFormatVersionTest,
-                         ::testing::Values(int8_t{1}, int8_t{2}));
+                         ::testing::Values(int8_t{1}, int8_t{2}, int8_t{3}));
 
 }  // namespace iceberg

Reply via email to