korsakowii commented on code in PR #4023:
URL: https://github.com/apache/iceberg-python/pull/4023#discussion_r4316268732


##########
tests/table/test_commit_retry.py:
##########
@@ -1290,3 +1290,140 @@ def 
test_negative_wait_properties_do_not_mask_commit_failure(catalog: Catalog) -
 
     result = catalog.load_table("default.negative_wait_test").scan().to_arrow()
     assert len(result) == 6
+
+
+def _commit_outcomes(catalog: Catalog, *outcomes: str) -> Any:
+    """Inject CommitFailedException before ("conflict") or after ("lost") a 
commit."""
+    real_commit = catalog.commit_table
+    remaining = iter(outcomes)
+
+    def commit(*args: Any, **kwargs: Any) -> Any:
+        outcome = next(remaining, "ok")
+        if outcome == "conflict":
+            raise CommitFailedException("concurrent update")
+        result = real_commit(*args, **kwargs)
+        if outcome == "lost":
+            raise CommitFailedException("response lost after the commit 
landed")
+        return result
+
+    return patch.object(catalog, "commit_table", side_effect=commit)
+
+
+def _assert_one_readable_snapshot(catalog: Catalog, identifier: str, expected: 
list[dict[str, Any]]) -> None:
+    table = catalog.load_table(identifier)
+    assert len(table.snapshots()) == 1
+    snapshot = table.current_snapshot()
+    assert snapshot is not None
+    assert table.io.new_input(snapshot.manifest_list).exists()
+    assert table.scan().to_arrow().to_pylist() == expected
+
+
[email protected](
+    ("num_retries", "outcomes"),
+    [
+        pytest.param("0", ["lost"], id="no-retries"),
+        pytest.param("1", ["conflict", "lost"], id="retries-exhausted"),
+    ],
+)
+def test_lost_response_on_the_last_attempt_keeps_the_landed_snapshot(
+    catalog: Catalog, num_retries: str, outcomes: list[str]
+) -> None:
+    """Check for a landed snapshot before cleanup, even when retries are 
exhausted."""
+    import pyarrow as pa
+
+    catalog.create_namespace("default")
+    table = catalog.create_table(
+        "default.last_attempt_lost",
+        schema=_test_schema(),
+        properties={
+            TableProperties.COMMIT_NUM_RETRIES: num_retries,
+            TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
+            TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
+        },
+    )
+
+    with _commit_outcomes(catalog, *outcomes):
+        table.append(pa.table({"x": [1]}))
+
+    _assert_one_readable_snapshot(catalog, "default.last_attempt_lost", [{"x": 
1}])
+
+
[email protected]("ignore:Delete operation did not match any 
records")
+def 
test_lost_response_for_an_overwrite_of_an_empty_table_keeps_the_landed_snapshot(catalog:
 Catalog) -> None:
+    """Recognize a landed overwrite even when its delete producer adds no 
snapshot.
+
+    Requiring a snapshot from every producer misses the landed append, so the 
rebuilt delete sees
+    that append as a conflict, and the ValidationException cleanup deletes the 
landed snapshot's files.
+    """
+    import pyarrow as pa
+
+    catalog.create_namespace("default")
+    table = catalog.create_table(
+        "default.empty_overwrite_lost",
+        schema=_test_schema(),
+        properties={
+            TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
+            TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
+        },
+    )
+
+    with _commit_outcomes(catalog, "lost"):
+        table.overwrite(pa.table({"x": [1]}))
+
+    _assert_one_readable_snapshot(catalog, "default.empty_overwrite_lost", 
[{"x": 1}])
+
+
+def 
test_retry_without_staged_snapshots_validates_against_refreshed_metadata(catalog:
 Catalog) -> None:
+    """Refresh before retrying a property update combined with a delete that 
matched nothing.
+
+    No snapshot is sent, so without a refresh the delete is rebuilt against 
stale metadata and misses
+    a concurrent append matching its predicate.
+    """
+    import pyarrow as pa
+
+    catalog.create_namespace("default")
+    table = catalog.create_table(
+        "default.no_snapshot_retry",
+        schema=_test_schema(),
+        properties={
+            TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "1",
+            TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "2",
+        },
+    )
+
+    tx = table.transaction()
+    tx.set_properties({"key": "value"})
+    with pytest.warns(UserWarning):  # the delete matches nothing at staging 
time
+        tx.delete("x > 45")
+
+    # A row matching the delete predicate lands concurrently.
+    catalog.load_table("default.no_snapshot_retry").append(pa.table({"x": 
[50]}))
+
+    with _commit_outcomes(catalog, "conflict"), 
pytest.raises(ValidationException):
+        tx.commit_transaction()
+
+    table = catalog.load_table("default.no_snapshot_retry")
+    assert "key" not in table.properties
+    assert table.scan().to_arrow()["x"].to_pylist() == [50]
+
+
+def test_lost_response_with_a_failed_refresh_keeps_the_files(catalog: Catalog) 
-> None:
+    """Preserve committed files when the outcome cannot be verified."""
+    import pyarrow as pa
+
+    catalog.create_namespace("default")
+    table = catalog.create_table(
+        "default.lost_refresh_failed",
+        schema=_test_schema(),
+        properties={TableProperties.COMMIT_NUM_RETRIES: "0"},
+    )
+
+    with (
+        _commit_outcomes(catalog, "lost"),
+        patch.object(table, "refresh", side_effect=ConnectionError("catalog 
unreachable")),
+        pytest.raises(CommitStateUnknownException) as exc_info,
+    ):
+        table.append(pa.table({"x": [1]}))
+    assert isinstance(exc_info.value.__cause__, CommitFailedException)
+
+    _assert_one_readable_snapshot(catalog, "default.lost_refresh_failed", 
[{"x": 1}])

Review Comment:
   Non-blocking: could these two cases be added to the regression coverage?
   
   The first sets `commit.retry.total-timeout-ms=0` while allowing one retry, 
so it deterministically covers the timeout terminal path rather than 
retry-count exhaustion.
   
   The second starts from a nonempty table, stages a delete that matches 
nothing, then appends a matching row. It exercises the no-snapshot delete 
producer and checks that the original snapshot's manifests remain available.
   
   Both use the existing helpers and assert a single catalog commit call during 
the lost response, snapshot counts, manifest availability, and exact final 
rows. The second also checks parent linkage. The initial seed commit is outside 
the injected failure.
   
   Proposed tests:
   
   ```python
   def 
test_lost_response_after_retry_timeout_keeps_the_landed_snapshot(catalog: 
Catalog) -> None:
       """Check for a landed commit when the timeout expires before the retry 
budget."""
       import pyarrow as pa
   
       catalog.create_namespace("default")
       table = catalog.create_table(
           "default.timeout_lost",
           schema=_test_schema(),
           properties={
               TableProperties.COMMIT_NUM_RETRIES: "1",
               TableProperties.COMMIT_TOTAL_RETRY_TIME_MS: "0",
           },
       )
   
       with _commit_outcomes(catalog, "lost") as commit:
           table.append(pa.table({"x": [1]}))
       commit.assert_called_once()
   
       _assert_one_readable_snapshot(catalog, "default.timeout_lost", [{"x": 
1}])
       table = catalog.load_table("default.timeout_lost")
       snapshot = table.current_snapshot()
       assert snapshot is not None
       manifests = snapshot.manifests(table.io)
       assert len(manifests) == 1
       assert table.io.new_input(manifests[0].manifest_path).exists()
   
   
   def 
test_lost_response_after_noop_delete_on_nonempty_table_keeps_the_landed_snapshot(catalog:
 Catalog) -> None:
       """Recognize a landed append when a preceding delete produced no 
snapshot."""
       import pyarrow as pa
   
       catalog.create_namespace("default")
       table = catalog.create_table(
           "default.nonempty_noop_delete_lost",
           schema=_test_schema(),
           properties={
               TableProperties.COMMIT_MIN_RETRY_WAIT_MS: "0",
               TableProperties.COMMIT_MAX_RETRY_WAIT_MS: "0",
           },
       )
       table.append(pa.table({"x": [1]}))
       initial_snapshot = table.current_snapshot()
       assert initial_snapshot is not None
   
       tx = table.transaction()
       with pytest.warns(UserWarning, match="Delete operation did not match any 
records"):
           tx.delete("x > 45")
       tx.append(pa.table({"x": [50]}))
   
       with _commit_outcomes(catalog, "lost") as commit:
           tx.commit_transaction()
       commit.assert_called_once()
   
       table = catalog.load_table("default.nonempty_noop_delete_lost")
       snapshots = table.snapshots()
       assert len(snapshots) == 2
       snapshot = table.current_snapshot()
       assert snapshot is not None
       assert snapshot.parent_snapshot_id == initial_snapshot.snapshot_id
       for committed_snapshot in snapshots:
           assert table.io.new_input(committed_snapshot.manifest_list).exists()
           manifests = committed_snapshot.manifests(table.io)
           assert manifests
           for manifest in manifests:
               assert table.io.new_input(manifest.manifest_path).exists()
       assert table.scan().to_arrow().sort_by("x").to_pylist() == [{"x": 1}, 
{"x": 50}]
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to