You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ke...@apache.org on 2026/08/23 04:24:25 UTC

(iceberg-python) branch main updated: Fail when explicitly deleted data file is missing (#3818)

This is an automated email from the ASF dual-hosted git repository.

kevinjqliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new 75396614a Fail when explicitly deleted data file is missing (#3818)
75396614a is described below

commit 75396614a5f093cf24e93c7d916ee2cda0bdf44e
Author: Alex Stephen <13...@users.noreply.github.com>
AuthorDate: Sat Aug 22 21:24:19 2026 -0700

    Fail when explicitly deleted data file is missing (#3818)
    
    * Fail when explicitly deleted data file is missing
    
    * Avoid writing manifests before overwrite validation
    
    ---------
    
    Co-authored-by: Kevin Liu <ke...@gmail.com>
---
 pyiceberg/table/update/snapshot.py | 29 +++++++++++--
 tests/table/test_snapshots.py      | 88 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 113 insertions(+), 4 deletions(-)

diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py
index e500e09fa..6bddd2790 100644
--- a/pyiceberg/table/update/snapshot.py
+++ b/pyiceberg/table/update/snapshot.py
@@ -247,8 +247,6 @@ class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
                 return []
 
         def _write_delete_manifest() -> list[ManifestFile]:
-            # Check if we need to mark the files as deleted
-            deleted_entries = self._deleted_entries()
             if len(deleted_entries) > 0:
                 deleted_manifests = []
                 partition_groups: dict[int, list[ManifestEntry]] = defaultdict(list)
@@ -265,6 +263,8 @@ class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
 
         # Updates self._predicate with computed partition predicate for manifest pruning
         self._build_delete_files_partition_predicate()
+        # Plan deletes before starting manifest writers so validation failures do not leave orphaned manifests
+        deleted_entries = self._deleted_entries()
 
         executor = ExecutorFactory.get_or_create()
 
@@ -850,9 +850,30 @@ class _OverwriteFiles(_SnapshotProducer["_OverwriteFiles"]):
                 ]
 
             list_of_entries = executor.map(_get_entries, previous_snapshot.manifests(self._io))
-            return list(itertools.chain(*list_of_entries))
+            deleted_entries = list(itertools.chain(*list_of_entries))
         else:
-            return []
+            deleted_entries = []
+
+        self._validate_required_deletes(deleted_entries)
+
+        return deleted_entries
+
+    def _validate_required_deletes(self, deleted_entries: list[ManifestEntry]) -> None:
+        """Validate that explicitly deleted data files exist in the parent snapshot.
+
+        Files passed to `delete_data_file` are required deletes. If one is absent, an overwrite
+        could commit replacement files without the corresponding deletion and produce incorrect
+        snapshot summary totals.
+
+        Args:
+            deleted_entries: Live parent-snapshot entries selected for deletion.
+
+        Raises:
+            ValidationException: If a required data file is missing.
+        """
+        found_data_files = {entry.data_file for entry in deleted_entries}
+        if missing := [data_file.file_path for data_file in self._deleted_data_files if data_file not in found_data_files]:
+            raise ValidationException(f"Missing required files to delete: {', '.join(sorted(missing))}")
 
 
 class UpdateSnapshot:
diff --git a/tests/table/test_snapshots.py b/tests/table/test_snapshots.py
index 5f1680ed5..0f72b0808 100644
--- a/tests/table/test_snapshots.py
+++ b/tests/table/test_snapshots.py
@@ -15,10 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 # pylint:disable=redefined-outer-name,eval-used
+import re
+import uuid
+from pathlib import Path
 from typing import cast
+from urllib.parse import urlparse
 
+import pyarrow as pa
 import pytest
 
+from pyiceberg.catalog import Catalog
+from pyiceberg.exceptions import ValidationException
+from pyiceberg.io.pyarrow import _dataframe_to_data_files
 from pyiceberg.manifest import DataFile, DataFileContent, ManifestContent, ManifestFile
 from pyiceberg.partitioning import PartitionField, PartitionSpec
 from pyiceberg.schema import Schema
@@ -649,3 +657,83 @@ def test_snapshot_producer_bounded_metadata_access(table_v2: Table) -> None:
             f"_MergeAppendFiles.__init__ made {merge_init - fast_init} extra update_table_metadata "
             "calls over its superclass; expected 1 (hoisted)"
         )
+
+
+@pytest.fixture
+def overwrite_table(catalog: Catalog, arrow_table_simple: pa.Table) -> Table:
+    catalog.create_namespace("default")
+    table = catalog.create_table("default.overwrite", arrow_table_simple.schema)
+    table.append(arrow_table_simple)
+    return table
+
+
+def _write_data_file(table: Table, rows: pa.Table) -> DataFile:
+    return next(
+        iter(
+            _dataframe_to_data_files(
+                table_metadata=table.metadata,
+                df=rows,
+                io=table.io,
+                write_uuid=uuid.uuid4(),
+            )
+        )
+    )
+
+
+def _total_data_file_count(table: Table) -> int:
+    snapshot = table.current_snapshot()
+    assert snapshot is not None and snapshot.summary is not None
+    return int(snapshot.summary.additional_properties["total-data-files"])
+
+
+def test_overwrite_replaces_existing_file(overwrite_table: Table, arrow_table_simple: pa.Table) -> None:
+    original_file = next(iter(overwrite_table.scan().plan_files())).file
+    replacement = _write_data_file(overwrite_table, arrow_table_simple.slice(0, 1))
+
+    with overwrite_table.transaction() as tx:
+        with tx.update_snapshot().overwrite() as overwrite:
+            overwrite.delete_data_file(original_file)
+            overwrite.append_data_file(replacement)
+
+    assert overwrite_table.scan().to_arrow()["foo"].to_pylist() == ["a"]
+    assert _total_data_file_count(overwrite_table) == 1
+
+
+def test_overwrite_rejects_explicit_delete_missing_from_base_snapshot(catalog: Catalog, overwrite_table: Table) -> None:
+    stale_file = next(iter(overwrite_table.scan().plan_files())).file
+    stale_rows = overwrite_table.scan().to_arrow()
+
+    # Delete the file before the replacement transaction begins
+    with catalog.load_table(overwrite_table.name()).transaction() as tx:
+        with tx.update_snapshot().overwrite() as overwrite:
+            overwrite.delete_data_file(stale_file)
+
+    current = catalog.load_table(overwrite_table.name())
+    replacement = _write_data_file(current, stale_rows)
+    expected_error = re.escape(f"Missing required files to delete: {stale_file.file_path}")
+
+    with pytest.raises(ValidationException, match=expected_error):
+        with current.transaction() as tx:
+            with tx.update_snapshot().overwrite() as overwrite:
+                overwrite.delete_data_file(stale_file)
+                overwrite.append_data_file(replacement)
+
+    metadata_path = Path(urlparse(current.location()).path) / "metadata"
+    assert not any(metadata_path.glob(f"{overwrite.commit_uuid}-m*.avro"))
+
+    committed = catalog.load_table(overwrite_table.name())
+    assert committed.scan().to_arrow()["foo"].to_pylist() == []
+    assert _total_data_file_count(committed) == 0
+
+
+def test_overwrite_rejects_explicit_delete_without_parent_snapshot(
+    catalog: Catalog, overwrite_table: Table, arrow_table_simple: pa.Table
+) -> None:
+    stale_file = next(iter(overwrite_table.scan().plan_files())).file
+    empty = catalog.create_table("default.empty", arrow_table_simple.schema)
+    expected_error = re.escape(f"Missing required files to delete: {stale_file.file_path}")
+
+    with pytest.raises(ValidationException, match=expected_error):
+        with empty.transaction() as tx:
+            with tx.update_snapshot().overwrite() as overwrite:
+                overwrite.delete_data_file(stale_file)