blob: ab95457e2328ac5088f2dc58d8bd37d2c33067f9 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import uuid
from typing import Any
from unittest.mock import patch
import pytest
from pyiceberg.catalog import Catalog
from pyiceberg.exceptions import CommitFailedException, CommitStateUnknownException, ValidationException
from pyiceberg.schema import Schema
from pyiceberg.table import TableProperties, Transaction
from pyiceberg.table.snapshots import IsolationLevel, Operation
from pyiceberg.types import LongType, NestedField, StringType
def test_isolation_level_enum() -> None:
assert IsolationLevel.SERIALIZABLE.value == "serializable"
assert IsolationLevel.SNAPSHOT.value == "snapshot"
assert IsolationLevel("serializable") is IsolationLevel.SERIALIZABLE
assert IsolationLevel("snapshot") is IsolationLevel.SNAPSHOT
def test_commit_retry_table_properties() -> None:
assert TableProperties.COMMIT_NUM_RETRIES == "commit.retry.num-retries"
assert TableProperties.COMMIT_NUM_RETRIES_DEFAULT == 4
assert TableProperties.COMMIT_MIN_RETRY_WAIT_MS == "commit.retry.min-wait-ms"
assert TableProperties.COMMIT_MIN_RETRY_WAIT_MS_DEFAULT == 100
assert TableProperties.COMMIT_MAX_RETRY_WAIT_MS == "commit.retry.max-wait-ms"
assert TableProperties.COMMIT_MAX_RETRY_WAIT_MS_DEFAULT == 60000
assert TableProperties.COMMIT_TOTAL_RETRY_TIME_MS == "commit.retry.total-timeout-ms"
assert TableProperties.COMMIT_TOTAL_RETRY_TIME_MS_DEFAULT == 1800000
def test_isolation_level_table_properties() -> None:
assert TableProperties.WRITE_DELETE_ISOLATION_LEVEL == "write.delete.isolation-level"
assert TableProperties.WRITE_UPDATE_ISOLATION_LEVEL == "write.update.isolation-level"
assert TableProperties.WRITE_ISOLATION_LEVEL_DEFAULT == "serializable"
def _test_schema() -> Schema:
return Schema(NestedField(1, "x", LongType(), required=False))
def test_commit_retry_on_commit_failed(catalog: Catalog) -> None:
"""Verify that CommitFailedException triggers retry for append operations."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.retry_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
# Load two references to the same table to simulate concurrent access
tbl1 = catalog.load_table("default.retry_test")
tbl2 = catalog.load_table("default.retry_test")
# First append succeeds
tbl1.append(df)
# Second append should succeed via retry (append vs append never conflicts)
import pyiceberg.table as _table_module
RuntimeTransaction = _table_module.Transaction
original_rebuild = RuntimeTransaction._rebuild_snapshot_updates
rebuild_count = 0
def counting_rebuild(self_tx: Any) -> None:
nonlocal rebuild_count
rebuild_count += 1
original_rebuild(self_tx)
with patch.object(RuntimeTransaction, "_rebuild_snapshot_updates", counting_rebuild):
tbl2.append(df)
assert rebuild_count == 1, "Expected exactly one retry via _rebuild_snapshot_updates"
# Both appends should be visible
refreshed = catalog.load_table("default.retry_test")
result = refreshed.scan().to_arrow()
assert len(result) == 6
def test_no_retry_without_snapshot_producers(catalog: Catalog) -> None:
"""Verify that a transaction with no snapshot producers has an empty producer list."""
catalog.create_namespace("default")
schema = _test_schema()
table = catalog.create_table("default.no_retry_test", schema=schema)
tx = Transaction(table, autocommit=False)
tx.set_properties({"key": "value"})
# No snapshot producers registered
assert len(tx._snapshot_producers) == 0
def test_rebuild_snapshot_updates_preserves_non_snapshot_updates(catalog: Catalog) -> None:
"""Verify that non-snapshot updates survive retry."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.rebuild_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1]})
tbl1 = catalog.load_table("default.rebuild_test")
tbl2 = catalog.load_table("default.rebuild_test")
# tbl1 commits first
tbl1.append(df)
# tbl2 does both property change and append in one transaction
with tbl2.transaction() as tx:
tx.set_properties({"test_key": "test_value"})
tx.append(df)
# Both the property and the data should be committed
refreshed = catalog.load_table("default.rebuild_test")
assert refreshed.metadata.properties.get("test_key") == "test_value"
assert len(refreshed.scan().to_arrow()) == 2
def test_refresh_for_retry_resets_producer_state(catalog: Catalog) -> None:
"""Verify that _refresh_for_retry resets the necessary fields."""
catalog.create_namespace("default")
schema = _test_schema()
table = catalog.create_table("default.refresh_test", schema=schema)
from pyiceberg.table.update.snapshot import _FastAppendFiles
tx = Transaction(table, autocommit=False)
producer = _FastAppendFiles(
operation=Operation.APPEND,
transaction=tx,
io=table.io,
)
original_snapshot_id = producer._snapshot_id
original_uuid = producer.commit_uuid
producer._refresh_for_retry()
# The snapshot id is kept stable across retries so it acts as an idempotency key.
assert producer._snapshot_id == original_snapshot_id
assert producer.commit_uuid != original_uuid
# parent stays None for empty table
assert producer._parent_snapshot_id is None
def test_concurrent_delete_delete_raises_validation_exception(catalog: Catalog) -> None:
"""Concurrent deletes on the same data should fail with ValidationException."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.del_del_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.del_del_test")
tbl.append(df)
tbl1 = catalog.load_table("default.del_del_test")
tbl2 = catalog.load_table("default.del_del_test")
tbl1.delete("x == 1")
with pytest.raises(ValidationException):
tbl2.delete("x == 1")
def test_concurrent_append_delete_raises_validation_exception(catalog: Catalog) -> None:
"""Delete after a concurrent append fails with ValidationException under serializable isolation."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.app_del_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.app_del_test")
tbl.append(df)
tbl1 = catalog.load_table("default.app_del_test")
tbl2 = catalog.load_table("default.app_del_test")
tbl1.append(df)
with pytest.raises(ValidationException):
tbl2.delete("x == 1")
def test_concurrent_delete_append_retries_successfully(catalog: Catalog) -> None:
"""Append after a concurrent delete should succeed via retry."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.del_app_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.del_app_test")
tbl.append(df)
tbl1 = catalog.load_table("default.del_app_test")
tbl2 = catalog.load_table("default.del_app_test")
tbl1.delete("x == 1")
import pyiceberg.table as _table_module
RuntimeTransaction = _table_module.Transaction
original_rebuild = RuntimeTransaction._rebuild_snapshot_updates
rebuild_count = 0
def counting_rebuild(self_tx: Any) -> None:
nonlocal rebuild_count
rebuild_count += 1
original_rebuild(self_tx)
with patch.object(RuntimeTransaction, "_rebuild_snapshot_updates", counting_rebuild):
tbl2.append(df)
assert rebuild_count == 1
refreshed = catalog.load_table("default.del_app_test")
result = refreshed.scan().to_arrow()
# Original 3 rows, minus 1 deleted, plus 3 appended = 5
assert len(result) == 5
def test_retry_exhaustion_raises_commit_failed(catalog: Catalog) -> None:
"""When retries are exhausted, CommitFailedException should be raised."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.exhaust_test",
schema=schema,
properties={"commit.retry.num-retries": "0"},
)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl1 = catalog.load_table("default.exhaust_test")
tbl2 = catalog.load_table("default.exhaust_test")
tbl1.append(df)
with pytest.raises(CommitFailedException):
tbl2.append(df)
def test_delete_files_refresh_clears_compute_deletes_cache(catalog: Catalog) -> None:
"""Verify that _refresh_for_retry clears the _compute_deletes cached property."""
catalog.create_namespace("default")
schema = _test_schema()
table = catalog.create_table("default.cache_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
table.append(df)
table = catalog.load_table("default.cache_test")
from pyiceberg.expressions import EqualTo
from pyiceberg.table.update.snapshot import _DeleteFiles
tx = Transaction(table, autocommit=False)
producer = _DeleteFiles(
operation=Operation.DELETE,
transaction=tx,
io=table.io,
)
producer.delete_by_predicate(EqualTo("x", 1))
# Access _compute_deletes to populate the cache
_ = producer._compute_deletes
assert "_compute_deletes" in producer.__dict__
producer._refresh_for_retry()
assert "_compute_deletes" not in producer.__dict__
def test_concurrent_overwrite_overwrite_raises_validation_exception(catalog: Catalog) -> None:
"""Concurrent overwrites on the same data should fail with ValidationException."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.ow_ow_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.ow_ow_test")
tbl.append(df)
tbl1 = catalog.load_table("default.ow_ow_test")
tbl2 = catalog.load_table("default.ow_ow_test")
tbl1.overwrite(pa.table({"x": [10, 20, 30]}), overwrite_filter="x > 0")
with pytest.raises(ValidationException):
tbl2.overwrite(pa.table({"x": [40, 50, 60]}), overwrite_filter="x > 0")
@pytest.mark.parametrize(
("concurrently_deleted_file", "expect_conflict", "expected_values"),
[
pytest.param("target", True, [1, 3], id="target-file"),
pytest.param("same-partition", False, [1, 2], id="same-partition-file"),
pytest.param("different-partition", False, [2, 3], id="different-partition-file"),
],
)
def test_file_overwrite_validates_concurrent_file_delete(
catalog: Catalog,
concurrently_deleted_file: str,
expect_conflict: bool,
expected_values: list[int],
) -> None:
"""A file replacement must fail only when its target file was concurrently deleted."""
import pyarrow as pa
from pyiceberg.io.pyarrow import _dataframe_to_data_files
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.transforms import IdentityTransform
catalog.create_namespace("default")
schema = Schema(
NestedField(1, "category", StringType(), required=False),
NestedField(2, "value", LongType(), required=False),
)
spec = PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="category"))
identifier = "default.concurrent_file_delete"
table = catalog.create_table(identifier, schema=schema, partition_spec=spec)
table.append(pa.table({"category": ["a", "b"], "value": [0, 1]}))
file_to_replace = next(task.file for task in table.scan().plan_files() if task.file.partition[0] == "a")
table.append(pa.table({"category": ["a"], "value": [3]}))
replacing_table = catalog.load_table(identifier)
deleting_table = catalog.load_table(identifier)
data_files = [task.file for task in deleting_table.scan().plan_files()]
file_to_delete = {
"target": file_to_replace,
"same-partition": next(
data_file for data_file in data_files if data_file.partition[0] == "a" and data_file != file_to_replace
),
"different-partition": next(data_file for data_file in data_files if data_file.partition[0] == "b"),
}[concurrently_deleted_file]
replacement_file = list(
_dataframe_to_data_files(
table_metadata=replacing_table.metadata,
df=pa.table({"category": ["a"], "value": [2]}),
io=replacing_table.io,
write_uuid=uuid.uuid4(),
)
)[0]
replacing_transaction = replacing_table.transaction()
with replacing_transaction.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(file_to_replace)
overwrite.append_data_file(replacement_file)
with deleting_table.transaction() as deleting_transaction:
with deleting_transaction.update_snapshot().overwrite() as overwrite:
overwrite.delete_data_file(file_to_delete)
if expect_conflict:
with pytest.raises(ValidationException, match="Data files were concurrently deleted"):
replacing_transaction.commit_transaction()
else:
replacing_transaction.commit_transaction()
result = catalog.load_table(identifier).scan().to_arrow()
assert sorted(result["value"].to_pylist()) == expected_values
def test_concurrent_overwrite_append_retries_successfully(catalog: Catalog) -> None:
"""Append after a concurrent overwrite should succeed via retry."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.ow_app_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.ow_app_test")
tbl.append(df)
tbl1 = catalog.load_table("default.ow_app_test")
tbl2 = catalog.load_table("default.ow_app_test")
tbl1.overwrite(pa.table({"x": [10, 20, 30]}), overwrite_filter="x > 0")
tbl2.append(pa.table({"x": [4, 5, 6]}))
refreshed = catalog.load_table("default.ow_app_test")
result = refreshed.scan().to_arrow()
# overwrite replaced 3 rows with 3 new rows, then append added 3 more = 6
assert len(result) == 6
def test_snapshot_isolation_allows_concurrent_append_delete(catalog: Catalog) -> None:
"""Under snapshot isolation, delete after a concurrent append should succeed via retry."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.snapshot_iso_test",
schema=schema,
properties={"write.delete.isolation-level": "snapshot"},
)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.snapshot_iso_test")
tbl.append(df)
tbl1 = catalog.load_table("default.snapshot_iso_test")
tbl2 = catalog.load_table("default.snapshot_iso_test")
tbl1.append(df)
# Under serializable this would raise ValidationException,
# but under snapshot isolation _validate_added_data_files is skipped
tbl2.delete("x == 1")
refreshed = catalog.load_table("default.snapshot_iso_test")
result = refreshed.scan().to_arrow()
# Original 3, delete removes x==1 from original (1 row), append adds 3 = 5
assert len(result) == 5
def test_uncommitted_manifests_tracked_correctly(catalog: Catalog) -> None:
"""Verify that uncommitted manifests are moved to _uncommitted_manifests on retry."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.manifest_track_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.manifest_track_test")
tbl.append(df)
tbl1 = catalog.load_table("default.manifest_track_test")
tbl2 = catalog.load_table("default.manifest_track_test")
tbl1.append(df)
import pyiceberg.table as _table_module2
RuntimeTransaction2 = _table_module2.Transaction
original_rebuild = RuntimeTransaction2._rebuild_snapshot_updates
uncommitted_count_during_rebuild = 0
def checking_rebuild(self_tx: Any) -> None:
nonlocal uncommitted_count_during_rebuild
original_rebuild(self_tx)
for producer in self_tx._snapshot_producers:
uncommitted_count_during_rebuild += len(producer._uncommitted_manifests)
with patch.object(RuntimeTransaction2, "_rebuild_snapshot_updates", checking_rebuild):
tbl2.append(df)
# After rebuild, the first attempt's manifests should be in _uncommitted_manifests
assert uncommitted_count_during_rebuild > 0
def test_concurrent_deletes_on_different_partitions_succeed(catalog: Catalog) -> None:
"""Concurrent deletes on different partitions should succeed via retry thanks to conflict detection filter."""
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.transforms import IdentityTransform
catalog.create_namespace("default")
schema = Schema(
NestedField(1, "category", StringType(), required=False),
NestedField(2, "value", LongType(), required=False),
)
spec = PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="category"))
catalog.create_table("default.part_del_test", schema=schema, partition_spec=spec)
import pyarrow as pa
df = pa.table(
{
"category": ["a", "a", "b", "b"],
"value": [1, 2, 3, 4],
}
)
tbl = catalog.load_table("default.part_del_test")
tbl.append(df)
tbl1 = catalog.load_table("default.part_del_test")
tbl2 = catalog.load_table("default.part_del_test")
# Delete from different partitions should not conflict
tbl1.delete("category == 'a'")
tbl2.delete("category == 'b'")
refreshed = catalog.load_table("default.part_del_test")
result = refreshed.scan().to_arrow()
assert len(result) == 0
def test_concurrent_partial_deletes_on_different_partitions_succeed(catalog: Catalog) -> None:
"""Concurrent partial deletes (CoW rewrite) on different partitions should succeed.
This tests the auto-computed partition predicate from _build_delete_files_partition_predicate.
"""
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.transforms import IdentityTransform
catalog.create_namespace("default")
schema = Schema(
NestedField(1, "category", StringType(), required=False),
NestedField(2, "value", LongType(), required=False),
)
spec = PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="category"))
catalog.create_table("default.part_partial_del_test", schema=schema, partition_spec=spec)
import pyarrow as pa
df = pa.table(
{
"category": ["a", "a", "b", "b"],
"value": [1, 2, 3, 4],
}
)
tbl = catalog.load_table("default.part_partial_del_test")
tbl.append(df)
tbl1 = catalog.load_table("default.part_partial_del_test")
tbl2 = catalog.load_table("default.part_partial_del_test")
# Partial delete: only value==1 in partition a, triggers CoW rewrite
tbl1.delete("value == 1")
# Partial delete: only value==3 in partition b, triggers CoW rewrite
tbl2.delete("value == 3")
refreshed = catalog.load_table("default.part_partial_del_test")
result = refreshed.scan().to_arrow()
# Original 4 rows, minus value==1 and value==3 = 2 rows remaining
assert len(result) == 2
def test_overwrite_uses_update_isolation_level(catalog: Catalog) -> None:
"""Verify that overwrite() reads write.update.isolation-level, not write.delete.isolation-level."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.update_iso_test",
schema=schema,
properties={
"write.delete.isolation-level": "serializable",
"write.update.isolation-level": "snapshot",
},
)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.update_iso_test")
tbl.append(df)
tbl1 = catalog.load_table("default.update_iso_test")
tbl2 = catalog.load_table("default.update_iso_test")
tbl1.append(df)
# Under write.delete.isolation-level=serializable this would raise ValidationException.
# But overwrite() uses write.update.isolation-level=snapshot, so it succeeds.
tbl2.overwrite(pa.table({"x": [10, 20, 30]}), overwrite_filter="x > 0")
refreshed = catalog.load_table("default.update_iso_test")
result = refreshed.scan().to_arrow()
# overwrite() uses write.update.isolation-level=snapshot, so it does not raise on the concurrent
# append. Under snapshot isolation that concurrent append (tbl1's rows) is preserved: the overwrite
# only replaces the rows it saw and then adds 3 new rows, leaving 6 in total.
assert len(result) == 6
def test_overwrite_with_serializable_update_isolation_raises(catalog: Catalog) -> None:
"""Verify that overwrite() raises ValidationException when write.update.isolation-level=serializable."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.update_serial_test",
schema=schema,
properties={
"write.update.isolation-level": "serializable",
},
)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.update_serial_test")
tbl.append(df)
tbl1 = catalog.load_table("default.update_serial_test")
tbl2 = catalog.load_table("default.update_serial_test")
tbl1.append(df)
with pytest.raises(ValidationException):
tbl2.overwrite(pa.table({"x": [10, 20, 30]}), overwrite_filter="x > 0")
def test_clean_all_uncommitted_on_validation_exception(catalog: Catalog) -> None:
"""Verify that all manifests are cleaned up when commit aborts with ValidationException."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.clean_abort_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
tbl = catalog.load_table("default.clean_abort_test")
tbl.append(df)
tbl1 = catalog.load_table("default.clean_abort_test")
tbl2 = catalog.load_table("default.clean_abort_test")
tbl1.delete("x == 1")
from pyiceberg.table.update.snapshot import _SnapshotProducer
captured_producers: list[Any] = []
original_clean_all = _SnapshotProducer._clean_all_uncommitted
def capturing_clean_all(self_producer: Any) -> None:
captured_producers.append(self_producer)
original_clean_all(self_producer)
with patch.object(_SnapshotProducer, "_clean_all_uncommitted", capturing_clean_all):
with pytest.raises(ValidationException):
tbl2.delete("x == 1")
# _clean_all_uncommitted was called on abort
assert len(captured_producers) > 0
# All manifest lists should be cleared
for producer in captured_producers:
assert producer._written_manifests == []
assert producer._uncommitted_manifests == []
def test_mixed_delete_overwrite_starts_from_catalog_snapshot(catalog: Catalog) -> None:
"""Mixed full-file and partial deletes should validate from the original table snapshot."""
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.table.update.snapshot import _DeleteFiles, _OverwriteFiles
from pyiceberg.transforms import IdentityTransform
catalog.create_namespace("default")
schema = Schema(
NestedField(1, "category", StringType(), required=False),
NestedField(2, "value", LongType(), required=False),
)
spec = PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="category"))
table = catalog.create_table("default.mixed_delete_start_snapshot", schema=schema, partition_spec=spec)
import pyarrow as pa
# Partition "a" has one row (will be fully deleted) and partition "b" has two rows (partial delete)
table.append(pa.table({"category": ["a", "b", "b"], "value": [1, 2, 3]}))
base_snapshot_id = table.metadata.current_snapshot_id
tx = Transaction(table, autocommit=False)
# "value == 1" deletes the entire file in partition "a" (full-file delete)
# "value == 2" partially deletes from partition "b" (CoW rewrite)
tx.delete("value <= 2")
assert len(tx._snapshot_producers) == 2
delete_producer = next(p for p in tx._snapshot_producers if isinstance(p, _DeleteFiles))
overwrite_producer = next(p for p in tx._snapshot_producers if isinstance(p, _OverwriteFiles))
assert delete_producer._starting_snapshot_id == base_snapshot_id
assert overwrite_producer._starting_snapshot_id == base_snapshot_id
def test_validate_concurrency_skips_when_commit_window_is_empty(catalog: Catalog) -> None:
"""Validation should be skipped when CommitWindow.is_empty() is True (no concurrent commits)."""
catalog.create_namespace("default")
schema = _test_schema()
table = catalog.create_table("default.missing_parent_test", schema=schema)
import pyarrow as pa
table.append(pa.table({"x": [1, 2, 3]}))
from pyiceberg.table.update.snapshot import CommitWindow, _DeleteFiles
tx = Transaction(table, autocommit=False)
producer = _DeleteFiles(
operation=Operation.DELETE,
transaction=tx,
io=table.io,
)
# CommitWindow where base == head means no concurrent commits occurred
assert table.metadata.current_snapshot_id is not None
current = table.metadata.snapshot_by_id(table.metadata.current_snapshot_id)
producer._commit_window = CommitWindow(base=current, head=current)
# Should not raise (validation is skipped)
producer._validate_concurrency()
def test_validate_concurrency_raises_on_missing_starting_snapshot(catalog: Catalog) -> None:
"""CommitWindow.resolve should raise when starting_snapshot_id cannot be resolved."""
catalog.create_namespace("default")
schema = _test_schema()
table = catalog.create_table("default.missing_starting_test", schema=schema)
import pyarrow as pa
table.append(pa.table({"x": [1, 2, 3]}))
from pyiceberg.table.update.snapshot import CommitWindow
with pytest.raises(ValidationException, match="Cannot find starting snapshot"):
CommitWindow.resolve(table.metadata, base_id=99999999, branch="main")
def test_mixed_delete_overwrite_retries_successfully(catalog: Catalog) -> None:
"""A mixed full-file + partial delete should succeed via retry, not raise ValidationException."""
from pyiceberg.partitioning import PartitionField, PartitionSpec
from pyiceberg.transforms import IdentityTransform
catalog.create_namespace("default")
schema = Schema(
NestedField(1, "category", StringType(), required=False),
NestedField(2, "value", LongType(), required=False),
)
spec = PartitionSpec(PartitionField(source_id=1, field_id=1000, transform=IdentityTransform(), name="category"))
catalog.create_table("default.mixed_retry_test", schema=schema, partition_spec=spec)
import pyarrow as pa
tbl = catalog.load_table("default.mixed_retry_test")
# 3 partitions, one data file each: a->[1,2], b->[3,4], c->[5,6]
tbl.append(pa.table({"category": ["a", "a", "b", "b", "c", "c"], "value": [1, 2, 3, 4, 5, 6]}))
tbl1 = catalog.load_table("default.mixed_retry_test")
tbl2 = catalog.load_table("default.mixed_retry_test")
# Concurrent append to partition 'c' (commits first, advances the HEAD)
tbl1.append(pa.table({"category": ["c"], "value": [7]}))
# Mixed delete on tbl2 (stale snapshot):
# partition 'a' is a partial rewrite (value==1 deleted, value==2 remains) -> _OverwriteFiles
# partition 'b' is a full delete (category == 'b') -> _DeleteFiles
# This should NOT conflict with the append to 'c', so retry should succeed.
tbl2.delete("value == 1 or category == 'b'")
result = catalog.load_table("default.mixed_retry_test").scan().to_arrow()
assert sorted(result.column("value").to_pylist()) == [2, 5, 6, 7]
def test_manifest_list_cleanup_on_retry(catalog: Catalog) -> None:
"""Verify that manifest list files from failed retry attempts are cleaned up."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.manifest_list_cleanup_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
# Seed the table so there's a branch HEAD to conflict with
tbl = catalog.load_table("default.manifest_list_cleanup_test")
tbl.append(df)
# Two writers see the same snapshot
tbl1 = catalog.load_table("default.manifest_list_cleanup_test")
tbl2 = catalog.load_table("default.manifest_list_cleanup_test")
# tbl1 commits first, advancing catalog HEAD
tbl1.append(df)
# Track deletes on tbl2
deleted_paths: list[str] = []
original_delete = tbl2.io.delete
def tracking_delete(path: str) -> None:
deleted_paths.append(path)
original_delete(path)
with patch.object(tbl2.io, "delete", side_effect=tracking_delete):
tbl2.append(df)
# At least one manifest list (snap-*.avro) from the failed attempt should be deleted
manifest_list_deletes = [p for p in deleted_paths if "snap-" in p and p.endswith(".avro")]
assert len(manifest_list_deletes) >= 1, (
f"Expected at least one orphaned manifest list to be cleaned up. Deleted paths: {deleted_paths}"
)
# Sanity check: all data committed
refreshed = catalog.load_table("default.manifest_list_cleanup_test")
assert len(refreshed.scan().to_arrow()) == 9
def test_manifest_list_cleanup_on_abort(catalog: Catalog) -> None:
"""Verify that ALL manifest lists are cleaned up when a commit permanently fails."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.manifest_list_abort_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
# Seed the table
tbl = catalog.load_table("default.manifest_list_abort_test")
tbl.append(df)
# Two writers see the same snapshot
tbl1 = catalog.load_table("default.manifest_list_abort_test")
tbl2 = catalog.load_table("default.manifest_list_abort_test")
# tbl1 deletes x==1, so tbl2's same delete will conflict
tbl1.delete("x == 1")
# Track deletes on tbl2
deleted_paths: list[str] = []
original_delete = tbl2.io.delete
def tracking_delete(path: str) -> None:
deleted_paths.append(path)
original_delete(path)
with patch.object(tbl2.io, "delete", side_effect=tracking_delete):
with pytest.raises(ValidationException):
tbl2.delete("x == 1")
# Manifest list files should be cleaned up on abort
manifest_list_deletes = [p for p in deleted_paths if "snap-" in p and p.endswith(".avro")]
assert len(manifest_list_deletes) >= 1, f"Expected manifest list cleanup on abort. Deleted paths: {deleted_paths}"
def test_commit_retry_on_non_main_branch(catalog: Catalog) -> None:
"""Verify that commit retry works correctly on a non-main branch."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.branch_retry_test", schema=schema)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
# Seed the table and create a branch
tbl = catalog.load_table("default.branch_retry_test")
tbl.append(df)
assert tbl.metadata.current_snapshot_id is not None
tbl.manage_snapshots().create_branch(snapshot_id=tbl.metadata.current_snapshot_id, branch_name="test-branch").commit()
# Two writers targeting the same branch
tbl1 = catalog.load_table("default.branch_retry_test")
tbl2 = catalog.load_table("default.branch_retry_test")
# tbl1 appends to the branch first
tbl1.append(df, branch="test-branch")
# tbl2 appends to the same branch (stale ref), should retry and succeed
import pyiceberg.table as _table_module
RuntimeTransaction = _table_module.Transaction
original_rebuild = RuntimeTransaction._rebuild_snapshot_updates
rebuild_count = 0
def counting_rebuild(self_tx: Any) -> None:
nonlocal rebuild_count
rebuild_count += 1
original_rebuild(self_tx)
with patch.object(RuntimeTransaction, "_rebuild_snapshot_updates", counting_rebuild):
tbl2.append(df, branch="test-branch")
assert rebuild_count == 1, "Expected exactly one retry on non-main branch"
# Both branch appends should be visible when scanning the branch
refreshed = catalog.load_table("default.branch_retry_test")
branch_snapshot_id = refreshed.metadata.refs["test-branch"].snapshot_id
result = refreshed.scan(snapshot_id=branch_snapshot_id).to_arrow()
assert len(result) == 9
def test_commit_retry_delete_on_non_main_branch(catalog: Catalog) -> None:
"""Verify that delete with retry works correctly on a non-main branch."""
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.branch_delete_retry_test",
schema=schema,
properties={"write.delete.isolation-level": "snapshot"},
)
import pyarrow as pa
df = pa.table({"x": [1, 2, 3]})
# Seed and create branch
tbl = catalog.load_table("default.branch_delete_retry_test")
tbl.append(df)
assert tbl.metadata.current_snapshot_id is not None
tbl.manage_snapshots().create_branch(snapshot_id=tbl.metadata.current_snapshot_id, branch_name="test-branch").commit()
# Append more data to the branch
tbl = catalog.load_table("default.branch_delete_retry_test")
tbl.append(pa.table({"x": [4, 5, 6]}), branch="test-branch")
# Two writers: one appends, one deletes on the same branch
tbl1 = catalog.load_table("default.branch_delete_retry_test")
tbl2 = catalog.load_table("default.branch_delete_retry_test")
# tbl1 appends to branch (non-conflicting with delete on different data)
tbl1.append(pa.table({"x": [7, 8, 9]}), branch="test-branch")
# tbl2 deletes x==1 on branch. Should retry (stale ref) and succeed
# because the concurrent append (x=7,8,9) does not conflict under snapshot isolation.
tbl2.delete("x == 1", branch="test-branch")
refreshed = catalog.load_table("default.branch_delete_retry_test")
branch_snapshot_id = refreshed.metadata.refs["test-branch"].snapshot_id
result = refreshed.scan(snapshot_id=branch_snapshot_id).to_arrow()
# Original: 1,2,3,4,5,6 + append 7,8,9 - delete x==1 = 2,3,4,5,6,7,8,9
assert sorted(result.column("x").to_pylist()) == [2, 3, 4, 5, 6, 7, 8, 9]
def test_negative_num_retries_still_commits(catalog: Catalog) -> None:
"""A negative commit.retry.num-retries is clamped to 0, so one attempt still runs (matches Java).
Without the clamp, range(num_retries + 1) becomes range(0), the commit is never attempted,
the staged updates are cleared, and the call returns as a silent no-op.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
catalog.create_table(
"default.negative_retries_test",
schema=schema,
properties={TableProperties.COMMIT_NUM_RETRIES: "-1"},
)
table = catalog.load_table("default.negative_retries_test")
table.append(pa.table({"x": [1]}))
assert catalog.load_table("default.negative_retries_test").scan().to_arrow().to_pylist() == [{"x": 1}]
def test_writer_that_started_on_an_empty_table_still_validates(catalog: Catalog) -> None:
"""A writer that started on an empty table must still validate against a concurrently-landed first snapshot.
When the starting snapshot is None (empty table), the commit window must not be treated as empty
if a head exists. Otherwise a concurrent first snapshot bypasses validation and its rows can be
deleted with no error.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
catalog.create_table(
"default.empty_start_validate",
schema=schema,
properties={
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
stale = catalog.load_table("default.empty_start_validate")
tx = stale.transaction()
with pytest.warns(UserWarning): # the delete matches nothing on an empty table
tx.overwrite(pa.table({"x": [2]}), overwrite_filter="x == 1")
# The first ever snapshot lands concurrently, with a row matching the filter.
catalog.load_table("default.empty_start_validate").append(pa.table({"x": [1]}))
with pytest.raises(ValidationException):
tx.commit_transaction()
def test_unknown_commit_outcome_keeps_the_committed_files(catalog: Catalog) -> None:
"""An exception with an unknown outcome must not delete files the committed snapshot may reference.
The cleanup on the retry loop should only run for exceptions that guarantee the commit did not
happen. A CommitStateUnknownException (e.g. a lost response after the catalog already committed)
is re-raised without deleting, so the live snapshot's manifests stay intact.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
catalog.create_table("default.unknown_outcome", schema=schema)
table = catalog.load_table("default.unknown_outcome")
real_commit = catalog.commit_table
def commit_then_lose_response(*args: Any, **kwargs: Any) -> Any:
real_commit(*args, **kwargs)
raise CommitStateUnknownException("response lost after the commit landed")
with patch.object(catalog, "commit_table", side_effect=commit_then_lose_response):
with pytest.raises(CommitStateUnknownException):
table.append(pa.table({"x": [1]}))
# The commit landed, so the table must still be readable.
assert catalog.load_table("default.unknown_outcome").scan().to_arrow().to_pylist() == [{"x": 1}]
def test_reusing_a_failed_transaction_cannot_publish_deleted_files(catalog: Catalog) -> None:
"""A transaction whose commit failed is invalidated, so reusing it cannot publish deleted files.
On failure the retry loop deletes the written manifests. Reusing the transaction would otherwise
re-stage updates that point at those deleted files, so the transaction refuses reuse instead.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
table = catalog.create_table("default.reuse_failed", schema=schema, properties={TableProperties.COMMIT_NUM_RETRIES: "0"})
table.append(pa.table({"x": [1]}))
tx = table.transaction()
tx.append(pa.table({"x": [2]}))
with patch.object(catalog, "commit_table", side_effect=CommitFailedException("transient")):
with pytest.raises(CommitFailedException):
tx.commit_transaction()
# Reusing the failed transaction must not corrupt the table.
with pytest.raises(RuntimeError):
tx.commit_transaction()
assert catalog.load_table("default.reuse_failed").scan().to_arrow().to_pylist() == [{"x": 1}]
def test_reusing_a_committed_transaction_does_not_damage_the_first_commit(catalog: Catalog) -> None:
"""Reusing a transaction after a successful commit must not replay the first commit's producers."""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
table = catalog.create_table(
"default.reuse_committed",
schema=schema,
properties={
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
tx = table.transaction()
tx.append(pa.table({"x": [1, 2, 3]}))
tx.commit_transaction()
tx.append(pa.table({"x": [10, 20]}))
catalog.load_table("default.reuse_committed").append(pa.table({"x": [100]})) # forces a retry
tx.commit_transaction()
rows = sorted(catalog.load_table("default.reuse_committed").scan().to_arrow()["x"].to_pylist())
assert rows == [1, 2, 3, 10, 20, 100]
def test_snapshot_isolation_delete_does_not_remove_rows_it_never_saw(catalog: Catalog) -> None:
"""Under snapshot isolation, a retried delete must not drop concurrently added rows it never saw.
The delete's planned file set is frozen at plan time, so replanning against a newer head on retry
does not turn a concurrently added, matching file into a delete target.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
table = catalog.create_table(
"default.snap_iso_delete",
schema=schema,
properties={
TableProperties.WRITE_DELETE_ISOLATION_LEVEL: "snapshot",
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
table.append(pa.table({"x": [0, 1]}))
stale = catalog.load_table("default.snap_iso_delete")
tx = stale.transaction()
tx.delete("x == 1")
# Lands after the delete was planned. The stale writer never saw this row.
catalog.load_table("default.snap_iso_delete").append(pa.table({"x": [1]}))
tx.commit_transaction()
rows = sorted(catalog.load_table("default.snap_iso_delete").scan().to_arrow()["x"].to_pylist())
assert rows == [0, 1]
def test_retried_delete_treats_a_concurrent_commit_atomically(catalog: Catalog) -> None:
"""A retried delete under snapshot isolation must treat a concurrent commit atomically."""
import uuid
import pyarrow as pa
from pyiceberg.io.pyarrow import _dataframe_to_data_files
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
table = catalog.create_table(
"default.retried_delete_atomic",
schema=schema,
properties={
TableProperties.WRITE_DELETE_ISOLATION_LEVEL: "snapshot",
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
table.append(pa.table({"x": [0, 1]}))
stale = catalog.load_table("default.retried_delete_atomic")
tx = stale.transaction()
tx.delete("x == 1")
# One concurrent commit carrying two files: [1] wholly matches, [1, 5] partially.
b = catalog.load_table("default.retried_delete_atomic")
btx = b.transaction()
with btx.update_snapshot().fast_append() as append:
for df in (pa.table({"x": [1]}), pa.table({"x": [1, 5]})):
for f in _dataframe_to_data_files(table_metadata=btx.table_metadata, io=b.io, write_uuid=uuid.uuid4(), df=df):
append.append_data_file(f)
btx.commit_transaction()
tx.commit_transaction()
final = sorted(catalog.load_table("default.retried_delete_atomic").scan().to_arrow()["x"].to_pylist())
# The concurrent commit is atomic, so the delete may apply to all of it or none of it.
assert final in ([0, 1, 1, 5], [0, 5]), f"half of the concurrent commit was deleted: {final}"
def test_commit_that_landed_but_was_reported_failed_is_not_committed_twice(catalog: Catalog) -> None:
"""A commit that landed but was reported as failed must not be committed a second time.
The snapshot id is stable across attempts, so a landed commit can be found in the refreshed
metadata on retry and is not repeated. Without this, the data would be committed twice and the
post-success cleanup would delete manifests the first committed snapshot references.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = Schema(NestedField(1, "x", LongType(), required=False))
catalog.create_table(
"default.landed_but_reported_failed",
schema=schema,
properties={
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
table = catalog.load_table("default.landed_but_reported_failed")
real_commit = catalog.commit_table
calls: list[int] = []
def commit_then_report_conflict(*args: Any, **kwargs: Any) -> Any:
result = real_commit(*args, **kwargs)
calls.append(1)
if len(calls) == 1:
raise CommitFailedException("transport layer retried; first response was lost")
return result
with patch.object(catalog, "commit_table", side_effect=commit_then_report_conflict):
table.append(pa.table({"x": [1]}))
assert catalog.load_table("default.landed_but_reported_failed").scan().to_arrow().to_pylist() == [{"x": 1}]
def test_delete_that_matched_nothing_at_staging_still_validates(catalog: Catalog) -> None:
"""A delete whose plan was empty at staging must still validate at commit time.
An empty-plan delete stages no updates, so commit_transaction would return before the
retry loop and validation. If a row matching the writer's own predicate lands between
staging and commit, serializable isolation requires a ValidationException rather than
a silent success that leaves the matching row in place.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.empty_plan_delete_test",
schema=schema,
properties={
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
},
)
tx = catalog.load_table("default.empty_plan_delete_test").transaction()
with pytest.warns(UserWarning): # the delete matches nothing at staging time
tx.delete("x > 45")
# A row matching the writer's own predicate lands concurrently.
catalog.load_table("default.empty_plan_delete_test").append(pa.table({"x": [50]}))
with pytest.raises(ValidationException):
tx.commit_transaction()
# The concurrent row is intact.
result = catalog.load_table("default.empty_plan_delete_test").scan().to_arrow()
assert result["x"].to_pylist() == [50]
def test_delete_that_matched_nothing_without_concurrent_commit_succeeds(catalog: Catalog) -> None:
"""An empty-plan delete with no concurrent activity commits cleanly as a no-op."""
import pyarrow as pa
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table("default.empty_plan_noop_test", schema=schema)
table = catalog.load_table("default.empty_plan_noop_test")
table.append(pa.table({"x": [1]}))
tx = catalog.load_table("default.empty_plan_noop_test").transaction()
with pytest.warns(UserWarning): # the delete matches nothing
tx.delete("x > 45")
tx.commit_transaction()
result = catalog.load_table("default.empty_plan_noop_test").scan().to_arrow()
assert result["x"].to_pylist() == [1]
def test_negative_wait_properties_do_not_mask_commit_failure(catalog: Catalog) -> None:
"""Negative wait properties are clamped so a retry does not die in time.sleep.
Without the clamp, a negative min-wait raises ValueError from time.sleep mid-retry,
masking the original CommitFailedException.
"""
import pyarrow as pa
catalog.create_namespace("default")
schema = _test_schema()
catalog.create_table(
"default.negative_wait_test",
schema=schema,
properties={
TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "-100",
TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "-1",
},
)
df = pa.table({"x": [1, 2, 3]})
tbl1 = catalog.load_table("default.negative_wait_test")
tbl2 = catalog.load_table("default.negative_wait_test")
tbl1.append(df)
# Succeeds via retry; a negative wait would raise ValueError from time.sleep instead.
tbl2.append(df)
result = catalog.load_table("default.negative_wait_test").scan().to_arrow()
assert len(result) == 6