| # 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 |