Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions pyiceberg/table/update/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import TYPE_CHECKING, Generic

from pyiceberg.avro.codecs import AvroCompressionCodec
from pyiceberg.exceptions import ValidationException
from pyiceberg.expressions import AlwaysFalse, BooleanExpression, Or
from pyiceberg.expressions.visitors import (
ROWS_MIGHT_NOT_MATCH,
Expand Down Expand Up @@ -667,9 +668,27 @@ def _get_entries(manifest: ManifestFile) -> list[ManifestEntry]:
]

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 every explicitly deleted data file is present in the current manifests.

A data file that was passed to `delete_data_file` can already be absent from the base
snapshot, for example when it was removed by an earlier commit. Committing anyway would
silently reintroduce the data of its replacement files, and skew the snapshot summary.

Raises:
ValidationException: If a data file to delete is missing from the current manifests.
"""
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:
Expand Down
83 changes: 83 additions & 0 deletions tests/table/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@
# specific language governing permissions and limitations
# under the License.
# pylint:disable=redefined-outer-name,eval-used
import re
import uuid
from typing import cast

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
Expand Down Expand Up @@ -649,3 +655,80 @@ def summary_calls(n_files: int) -> int:
f"_MergeAppendFiles.__init__ made {merge_init - fast_init} extra update_table_metadata "
"calls over its superclass; expected 1 (hoisted)"
)


def _rewrite(table: Table, df: pa.Table) -> DataFile:
return next(
iter(
_dataframe_to_data_files(
table_metadata=table.metadata,
df=df,
io=table.io,
write_uuid=uuid.uuid4(),
)
)
)


def _total_data_files(table: Table) -> str:
snapshot = table.current_snapshot()
assert snapshot is not None and snapshot.summary is not None
return snapshot.summary.additional_properties["total-data-files"]


def test_overwrite_replaces_a_file_that_is_present(catalog: Catalog, arrow_table_simple: pa.Table) -> None:
catalog.create_namespace("default")
table = catalog.create_table("default.overwrite", arrow_table_simple.schema)
table.append(arrow_table_simple)

data_file = list(table.scan().plan_files())[0].file
replacement = _rewrite(table, arrow_table_simple.slice(0, 1))

with table.transaction() as tx:
with tx.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(data_file)
overwrite.append_data_file(replacement)

assert table.scan().to_arrow()["foo"].to_pylist() == ["a"]
assert _total_data_files(table) == "1"


def test_overwrite_rejects_file_missing_from_base(catalog: Catalog, arrow_table_simple: pa.Table) -> None:
catalog.create_namespace("default")
table = catalog.create_table("default.overwrite", arrow_table_simple.schema)
table.append(arrow_table_simple)

stale_file = list(table.scan().plan_files())[0].file
stale_rows = table.scan().to_arrow()

# Delete the file before the replacement transaction begins
with catalog.load_table("default.overwrite").transaction() as tx:
with tx.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(stale_file)

current = catalog.load_table("default.overwrite")
replacement = _rewrite(current, stale_rows)

with pytest.raises(ValidationException, match=re.escape(f"Missing required files to delete: {stale_file.file_path}")):
with current.transaction() as tx:
with tx.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(stale_file)
overwrite.append_data_file(replacement)

committed = catalog.load_table("default.overwrite")
assert committed.scan().to_arrow()["foo"].to_pylist() == []
assert _total_data_files(committed) == "0"


def test_overwrite_rejects_deletes_without_a_parent_snapshot(catalog: Catalog, arrow_table_simple: pa.Table) -> None:
catalog.create_namespace("default")
table = catalog.create_table("default.overwrite", arrow_table_simple.schema)
table.append(arrow_table_simple)

stale_file = list(table.scan().plan_files())[0].file
empty = catalog.create_table("default.empty", arrow_table_simple.schema)

with pytest.raises(ValidationException, match=re.escape(f"Missing required files to delete: {stale_file.file_path}")):
with empty.transaction() as tx:
with tx.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(stale_file)