qzyu999 commented on code in PR #3320:
URL: https://github.com/apache/iceberg-python/pull/3320#discussion_r3425488390


##########
pyiceberg/table/update/snapshot.py:
##########
@@ -91,19 +96,35 @@ def _new_manifest_list_file_name(snapshot_id: int, attempt: 
int, commit_uuid: uu
     return f"snap-{snapshot_id}-{attempt}-{commit_uuid}.avro"
 
 
+@dataclass
+class CommitWindow:
+    """Tracks the commit range to validate against during retry.
+
+    starting_snapshot_id: The snapshot when the operation began (fixed across 
retries).
+    catalog_head_snapshot_id: The catalog's latest HEAD snapshot (updated on 
each retry).
+    """
+
+    starting_snapshot_id: int | None
+    catalog_head_snapshot_id: int | None
+
+
 class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
     commit_uuid: uuid.UUID
     _io: FileIO
     _operation: Operation
     _snapshot_id: int
     _parent_snapshot_id: int | None
+    _starting_snapshot_id: int | None
     _added_data_files: list[DataFile]
     _manifest_num_counter: itertools.count[int]
     _deleted_data_files: set[DataFile]
     _compression: AvroCompressionCodec
     _target_branch: str | None
     _predicate: BooleanExpression
     _case_sensitive: bool
+    _commit_window: CommitWindow | None
+    _written_manifests: list[str]
+    _uncommitted_manifests: list[str]
 

Review Comment:
   We can make sure that `_SnapshotProducer` has a variable to track all 
manifest lists like with `_written_manifests`:
   ```python
       _written_manifest_lists: list[str]
   ```



##########
pyiceberg/table/update/snapshot.py:
##########


Review Comment:
   It's at this point where we create the manifest list, but never save it 
anywhere so it becomes orphaned in the case of a failed commit. So we add it to 
our `_written_manifest_lists` variable here:
   ```python
           self._written_manifest_lists.append(manifest_list_file_path)
   ```



##########
pyiceberg/table/update/snapshot.py:
##########
@@ -353,11 +379,123 @@ def new_manifest_output(self) -> OutputFile:
         location_provider = self._transaction._table.location_provider()
         file_name = 
_new_manifest_file_name(num=next(self._manifest_num_counter), 
commit_uuid=self.commit_uuid)
         file_path = location_provider.new_metadata_location(file_name)
+        self._written_manifests.append(file_path)
         return self._io.new_output(file_path)
 
     def fetch_manifest_entry(self, manifest: ManifestFile, discard_deleted: 
bool = True) -> list[ManifestEntry]:
         return manifest.fetch_manifest_entry(io=self._io, 
discard_deleted=discard_deleted)
 
+    def commit(self) -> None:
+        self._transaction._register_snapshot_producer(self)
+        self._transaction._apply(*self._commit())
+
+    def _cleanup_uncommitted(self) -> None:
+        """Delete manifest files from failed retry attempts."""
+        for path in self._uncommitted_manifests:
+            try:
+                self._io.delete(path)
+            except Exception:
+                logger.warning("Failed to delete uncommitted manifest: %s", 
path, exc_info=True)
+        self._uncommitted_manifests.clear()
+
+    def _clean_all_uncommitted(self) -> None:
+        """Clean up all manifests written during this producer's lifecycle on 
abort."""
+        for path in itertools.chain(self._uncommitted_manifests, 
self._written_manifests):
+            try:
+                self._io.delete(path)
+            except Exception:
+                logger.warning("Failed to delete uncommitted manifest: %s", 
path, exc_info=True)
+        self._uncommitted_manifests.clear()
+        self._written_manifests.clear()

Review Comment:
   In `_cleanup_uncommitted` and `_clean_all_uncommitted`, we make sure to also 
delete the potentially orphaned manifest lists, copying the pattern in the Java 
implementation where after a successful commit, all but the manifest list that 
was actually used:
   
   ```python
       def _cleanup_uncommitted(self) -> None:
           """Delete manifest files and manifest lists from failed retry 
attempts."""
           for path in self._uncommitted_manifests:
               try:
                   self._io.delete(path)
               except Exception:
                   logger.warning("Failed to delete uncommitted manifest: %s", 
path, exc_info=True)
           self._uncommitted_manifests.clear()
   
           # Delete all manifest lists except the last one (which the committed 
snapshot references)
           if len(self._written_manifest_lists) > 1:
               for path in self._written_manifest_lists[:-1]:
                   try:
                       self._io.delete(path)
                   except Exception:
                       logger.warning("Failed to delete uncommitted manifest 
list: %s", path, exc_info=True)
               self._written_manifest_lists = self._written_manifest_lists[-1:]
   
       def _clean_all_uncommitted(self) -> None:
           """Clean up all manifests and manifest lists on abort."""
           for path in itertools.chain(self._uncommitted_manifests, 
self._written_manifests):
               try:
                   self._io.delete(path)
               except Exception:
                   logger.warning("Failed to delete uncommitted manifest: %s", 
path, exc_info=True)
           for path in self._written_manifest_lists:
               try:
                   self._io.delete(path)
               except Exception:
                   logger.warning("Failed to delete uncommitted manifest list: 
%s", path, exc_info=True)
           self._uncommitted_manifests.clear()
           self._written_manifests.clear()
           self._written_manifest_lists.clear()
   ```



##########
pyiceberg/table/update/snapshot.py:
##########
@@ -123,6 +144,8 @@ def __init__(
         self._deleted_data_files = set()
         self.snapshot_properties = snapshot_properties
         self._manifest_num_counter = itertools.count(0)
+        self._written_manifests = []
+        self._uncommitted_manifests = []
         from pyiceberg.table import TableProperties

Review Comment:
   The variable is initialized as an empty list like `_written_manifests`:
   ```python
           self._written_manifest_lists = []
   ```



##########
tests/table/test_validate.py:
##########


Review Comment:
   Here we can add two tests, checking that the manifest list clean up works on 
retry and abort:
   
   ```python
   def test_manifest_list_cleanup_on_retry(catalog: Catalog) -> None:
       """Verify that manifest list files from failed retry attempts are 
cleaned up.
   
       Background:
       - Each commit attempt writes a manifest list file 
(snap-{id}-0-{uuid}.avro)
       - When a commit fails and retries, the OLD manifest list becomes orphaned
       - This test verifies that after a successful retry, the orphaned manifest
         list from the failed attempt is deleted from storage
   
       Flow:
       1. tbl1 and tbl2 both load the table at snapshot S1
       2. tbl1.append() succeeds, advancing catalog to S2
       3. tbl2.append() attempt 1:
          - Writes manifest list "snap-A-0-{uuid1}.avro" to storage
          - _do_commit() fails because tbl2 expected S1 but catalog is at S2
       4. tbl2.append() attempt 2 (retry):
          - Writes NEW manifest list "snap-B-0-{uuid2}.avro"
          - _do_commit() succeeds
       5. Cleanup:
          - "snap-A-0-{uuid1}.avro" should be DELETED (it's orphaned)
          - "snap-B-0-{uuid2}.avro" stays (referenced by committed snapshot)
       """
       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 with one snapshot so there's a branch HEAD to conflict 
with
       tbl = catalog.load_table("default.manifest_list_cleanup_test")
       tbl.append(df)
   
       # Load the same table twice to simulate two concurrent writers.
       # Both see the SAME snapshot (S1). Whoever commits second will get
       # CommitFailedException on their first attempt.
       tbl1 = catalog.load_table("default.manifest_list_cleanup_test")
       tbl2 = catalog.load_table("default.manifest_list_cleanup_test")
   
       # tbl1 commits first — advances catalog HEAD to S2.
       # tbl2 still thinks HEAD is S1, so its next commit will fail on first 
attempt.
       tbl1.append(df)
   
       # Intercept tbl2.io.delete() to record every file path that gets deleted.
       # This lets us verify cleanup happened without inspecting internal state.
       deleted_paths: list[str] = []
       original_delete = tbl2.io.delete
   
       def tracking_delete(path: str) -> None:
           deleted_paths.append(path)
           original_delete(path)
   
       # tbl2.append(df) will:
       #   - Attempt 1: write manifest list → fail with CommitFailedException
       #   - Retry: refresh metadata, rebuild snapshot, write NEW manifest list 
→ succeed
       #   - Cleanup: delete the manifest list from attempt 1
       with patch.object(tbl2.io, "delete", side_effect=tracking_delete):
           tbl2.append(df)
   
       # ASSERTION: At least one manifest list file (snap-*.avro) was deleted.
       # This proves the orphaned manifest list from the failed first attempt
       # was tracked and cleaned up. Without the _written_manifest_lists 
tracking,
       # this list would be empty — only individual manifests ({uuid}-m0.avro) 
would
       # appear in deleted_paths.
       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 (snap-*-*.avro) to be 
cleaned up.\n"
           f"Deleted paths were: {deleted_paths}\n"
           f"This means the manifest list from the failed retry attempt was NOT 
tracked for cleanup."
       )
   
       # Sanity check: the actual data commit succeeded (both appends visible)
       refreshed = catalog.load_table("default.manifest_list_cleanup_test")
       assert len(refreshed.scan().to_arrow()) == 9  # 3 (seed) + 3 (tbl1) + 3 
(tbl2)
   
   
   def test_manifest_list_cleanup_on_abort(catalog: Catalog) -> None:
       """Verify that ALL manifest lists are cleaned up when a commit 
permanently fails.
   
       Background:
       - When validation detects an unresolvable conflict (e.g. two writers 
deleted
         the same rows), it raises ValidationException — no more retries.
       - On abort, _clean_all_uncommitted() should delete ALL written files,
         including manifest list files, not just manifests.
   
       Flow:
       1. Table has rows [1, 2, 3]. tbl1 and tbl2 both load at this snapshot.
       2. tbl1.delete("x == 1") succeeds — removes row 1, advances catalog HEAD.
       3. tbl2.delete("x == 1") attempt 1:
          - Writes manifest list to storage
          - _do_commit() fails (stale snapshot)
       4. Retry validation:
          - Refreshes metadata, sees tbl1 already deleted files matching "x == 
1"
          - _validate_concurrency() raises ValidationException (conflicting 
deletes)
       5. Abort path (except Exception):
          - _clean_all_uncommitted() should delete ALL manifests AND manifest 
lists
          - The manifest list from attempt 1 should be deleted
       """
       catalog.create_namespace("default")
       schema = _test_schema()
       catalog.create_table("default.manifest_list_abort_test", schema=schema)
   
       import pyarrow as pa
   
       from pyiceberg.exceptions import ValidationException
   
       df = pa.table({"x": [1, 2, 3]})
   
       # Seed the table
       tbl = catalog.load_table("default.manifest_list_abort_test")
       tbl.append(df)
   
       # Two writers looking at 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 first. This means the data file containing x==1
       # has been removed. When tbl2 tries the same delete, validation will
       # detect this as a conflict (the file it wants to delete is already 
gone).
       tbl1.delete("x == 1")
   
       # Intercept deletes to verify cleanup
       deleted_paths: list[str] = []
       original_delete = tbl2.io.delete
   
       def tracking_delete(path: str) -> None:
           deleted_paths.append(path)
           original_delete(path)
   
       # tbl2.delete("x == 1") will:
       #   - Attempt 1: write manifest + manifest list → CommitFailedException
       #   - Retry: refresh, _validate_concurrency() sees tbl1 already deleted
       #     the same files → raises ValidationException (permanent failure)
       #   - Abort: _clean_all_uncommitted() should delete ALL written artifacts
       with patch.object(tbl2.io, "delete", side_effect=tracking_delete):
           with pytest.raises(ValidationException):
               tbl2.delete("x == 1")
   
       # ASSERTION: At least one manifest list file was deleted on abort.
       # _clean_all_uncommitted() iterates _written_manifest_lists and deletes 
them all.
       # Without tracking, this would be empty — only manifests in 
_written_manifests
       # and _uncommitted_manifests would be cleaned.
       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 (ValidationException).\n"
           f"Deleted paths were: {deleted_paths}\n"
           f"This means _clean_all_uncommitted() did not clean manifest list 
files."
       )
   ```



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