Skip to content

Commit dfd2b43

Browse files
committed
Validate against full history when starting from an empty table
A writer that started on a table with no snapshots treated the commit window as empty and skipped validation. If another writer landed the first snapshot concurrently, its rows could be dropped with no error. Treat a None base with a live head as a full-history validation, matching Java. Signed-off-by: Sotaro Hikita <bering1814@gmail.com>
1 parent e79e19b commit dfd2b43

3 files changed

Lines changed: 49 additions & 14 deletions

File tree

pyiceberg/table/update/snapshot.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,13 @@ def resolve(cls, metadata: TableMetadata, base_id: int | None, branch: str | Non
118118
return cls(base=base, head=head)
119119

120120
def is_empty(self) -> bool:
121-
"""Return True if no concurrent commits occurred (validation can be skipped)."""
122-
return self.head is None or self.base is None or self.base.snapshot_id == self.head.snapshot_id
121+
"""Return True if no concurrent commits occurred (validation can be skipped).
122+
123+
A None base means the operation started on a table with no snapshots. If a head
124+
exists, another writer landed the first snapshot concurrently, so the window is not
125+
empty and validation must run against the whole history.
126+
"""
127+
return self.head is None or (self.base is not None and self.base.snapshot_id == self.head.snapshot_id)
123128

124129

125130
class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
@@ -476,7 +481,7 @@ def _validate_concurrency(self) -> None:
476481
catalog_head = self._commit_window.head
477482
starting_snapshot = self._commit_window.base
478483

479-
if catalog_head is None or starting_snapshot is None:
484+
if catalog_head is None:
480485
return
481486

482487
table = self._transaction._table

pyiceberg/table/update/validate.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,20 @@
4040

4141
def _validation_history(
4242
table: Table,
43-
from_snapshot: Snapshot,
43+
from_snapshot: Snapshot | None,
4444
to_snapshot: Snapshot,
4545
matching_operations: set[Operation],
4646
manifest_content_filter: ManifestContent,
4747
) -> tuple[list[ManifestFile], set[int]]:
4848
"""Return newly added manifests and snapshot IDs between the starting snapshot and parent snapshot.
4949
5050
Walks from to_snapshot backwards towards from_snapshot, collecting manifests from
51-
snapshots whose operations match. from_snapshot is excluded from results.
51+
snapshots whose operations match. from_snapshot is excluded from results. A None
52+
from_snapshot walks the entire history of to_snapshot down to the root.
5253
5354
Args:
5455
table: Table to get the history from
55-
from_snapshot: Snapshot where the walk stops (exclusive)
56+
from_snapshot: Snapshot where the walk stops (exclusive), or None to walk the whole history
5657
to_snapshot: Snapshot where the walk starts
5758
matching_operations: Operations to match on
5859
manifest_content_filter: Manifest content type to filter
@@ -63,15 +64,15 @@ def _validation_history(
6364
Returns:
6465
List of manifest files and set of snapshots ID's matching conditions
6566
"""
66-
if from_snapshot.snapshot_id == to_snapshot.snapshot_id:
67+
if from_snapshot is not None and from_snapshot.snapshot_id == to_snapshot.snapshot_id:
6768
return [], set()
6869

6970
manifests_files: list[ManifestFile] = []
7071
snapshots: set[int] = set()
7172

7273
last_snapshot = None
7374
for snapshot in ancestors_between(from_snapshot, to_snapshot, table.metadata):
74-
if snapshot.snapshot_id == from_snapshot.snapshot_id:
75+
if from_snapshot is not None and snapshot.snapshot_id == from_snapshot.snapshot_id:
7576
last_snapshot = snapshot
7677
break
7778
last_snapshot = snapshot
@@ -90,7 +91,7 @@ def _validation_history(
9091
]
9192
)
9293

93-
if last_snapshot is None or last_snapshot.snapshot_id != from_snapshot.snapshot_id:
94+
if from_snapshot is not None and (last_snapshot is None or last_snapshot.snapshot_id != from_snapshot.snapshot_id):
9495
raise ValidationException("No matching snapshot found.")
9596

9697
return manifests_files, snapshots
@@ -216,9 +217,6 @@ def _added_data_files(
216217
Returns:
217218
Iterator of manifest entries for added data files matching the conditions
218219
"""
219-
if parent_snapshot is None:
220-
return
221-
222220
manifests, snapshot_ids = _validation_history(
223221
table,
224222
parent_snapshot,
@@ -252,7 +250,7 @@ def _added_delete_files(
252250
Returns:
253251
DeleteFileIndex
254252
"""
255-
if parent_snapshot is None or table.format_version < 2:
253+
if table.format_version < 2:
256254
return DeleteFileIndex()
257255

258256
manifests, snapshot_ids = _validation_history(
@@ -352,7 +350,7 @@ def _validate_no_new_deletes_for_data_files(
352350
parent_snapshot: Ending snapshot on the branch being validated
353351
"""
354352
# If there is no current state, or no files has been added
355-
if parent_snapshot is None or table.format_version < 2:
353+
if table.format_version < 2:
356354
return
357355

358356
deletes = _added_delete_files(table, starting_snapshot, data_filter, None, parent_snapshot)

tests/table/test_commit_retry.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,3 +902,35 @@ def test_negative_num_retries_still_commits(catalog: Catalog) -> None:
902902
table.append(pa.table({"x": [1]}))
903903

904904
assert catalog.load_table("default.negative_retries_test").scan().to_arrow().to_pylist() == [{"x": 1}]
905+
906+
907+
def test_writer_that_started_on_an_empty_table_still_validates(catalog: Catalog) -> None:
908+
"""A writer that started on an empty table must still validate against a concurrently-landed first snapshot.
909+
910+
When the starting snapshot is None (empty table), the commit window must not be treated as empty
911+
if a head exists. Otherwise a concurrent first snapshot bypasses validation and its rows can be
912+
deleted with no error.
913+
"""
914+
import pyarrow as pa
915+
916+
catalog.create_namespace("default")
917+
schema = Schema(NestedField(1, "x", LongType(), required=False))
918+
catalog.create_table(
919+
"default.empty_start_validate",
920+
schema=schema,
921+
properties={
922+
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
923+
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
924+
},
925+
)
926+
927+
stale = catalog.load_table("default.empty_start_validate")
928+
tx = stale.transaction()
929+
with pytest.warns(UserWarning): # the delete matches nothing on an empty table
930+
tx.overwrite(pa.table({"x": [2]}), overwrite_filter="x == 1")
931+
932+
# The first ever snapshot lands concurrently, with a row matching the filter.
933+
catalog.load_table("default.empty_start_validate").append(pa.table({"x": [1]}))
934+
935+
with pytest.raises(ValidationException):
936+
tx.commit_transaction()

0 commit comments

Comments
 (0)