1717from __future__ import annotations
1818
1919import itertools
20+ import logging
2021import uuid
2122from abc import abstractmethod
2223from collections import defaultdict
8081if TYPE_CHECKING :
8182 from pyiceberg .table import Transaction
8283
84+ logger = logging .getLogger (__name__ )
85+
8386
8487def _new_manifest_file_name (num : int , commit_uuid : uuid .UUID ) -> str :
8588 return f"{ commit_uuid } -m{ num } .avro"
@@ -104,6 +107,8 @@ class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
104107 _target_branch : str | None
105108 _predicate : BooleanExpression
106109 _case_sensitive : bool
110+ _written_manifests : list [str ]
111+ _uncommitted_manifests : list [str ]
107112
108113 def __init__ (
109114 self ,
@@ -123,6 +128,8 @@ def __init__(
123128 self ._deleted_data_files = set ()
124129 self .snapshot_properties = snapshot_properties
125130 self ._manifest_num_counter = itertools .count (0 )
131+ self ._written_manifests = []
132+ self ._uncommitted_manifests = []
126133 from pyiceberg .table import TableProperties
127134
128135 self ._compression = self ._transaction .table_metadata .properties .get ( # type: ignore
@@ -351,11 +358,39 @@ def new_manifest_output(self) -> OutputFile:
351358 location_provider = self ._transaction ._table .location_provider ()
352359 file_name = _new_manifest_file_name (num = next (self ._manifest_num_counter ), commit_uuid = self .commit_uuid )
353360 file_path = location_provider .new_metadata_location (file_name )
361+ self ._written_manifests .append (file_path )
354362 return self ._io .new_output (file_path )
355363
356364 def fetch_manifest_entry (self , manifest : ManifestFile , discard_deleted : bool = True ) -> list [ManifestEntry ]:
357365 return manifest .fetch_manifest_entry (io = self ._io , discard_deleted = discard_deleted )
358366
367+ def commit (self ) -> None :
368+ self ._transaction ._register_snapshot_producer (self )
369+ self ._transaction ._apply (* self ._commit ())
370+
371+ def _cleanup_uncommitted (self ) -> None :
372+ """Delete manifest files from failed retry attempts."""
373+ for path in self ._uncommitted_manifests :
374+ try :
375+ self ._io .delete (path )
376+ except Exception :
377+ logger .warning ("Failed to delete uncommitted manifest: %s" , path , exc_info = True )
378+ self ._uncommitted_manifests .clear ()
379+
380+ def _refresh_for_retry (self ) -> None :
381+ """Reset state for a retry attempt with refreshed metadata."""
382+ self ._uncommitted_manifests .extend (self ._written_manifests )
383+ self ._written_manifests .clear ()
384+ self ._parent_snapshot_id = (
385+ snapshot .snapshot_id if (snapshot := self ._transaction .table_metadata .snapshot_by_name (self ._target_branch )) else None
386+ )
387+ self ._snapshot_id = self ._transaction .table_metadata .new_snapshot_id ()
388+ self ._manifest_num_counter = itertools .count (0 )
389+ self .commit_uuid = uuid .uuid4 ()
390+
391+ def _validate_concurrency (self ) -> None :
392+ """Validate that concurrent changes do not conflict with this operation. No-op by default."""
393+
359394 def _build_partition_projection (self , spec_id : int ) -> BooleanExpression :
360395 project = inclusive_projection (self .schema (), self .spec (spec_id ), self ._case_sensitive )
361396 return project (self ._predicate )
@@ -495,6 +530,48 @@ def files_affected(self) -> bool:
495530 """Indicate if any manifest-entries can be dropped."""
496531 return len (self ._deleted_entries ()) > 0
497532
533+ def _refresh_for_retry (self ) -> None :
534+ """Reset state for a retry attempt, clearing the cached delete computation."""
535+ super ()._refresh_for_retry ()
536+ if "_compute_deletes" in self .__dict__ :
537+ del self .__dict__ ["_compute_deletes" ]
538+
539+ def _validate_concurrency (self ) -> None :
540+ """Validate that concurrent changes do not conflict with this delete."""
541+ from pyiceberg .table import TableProperties
542+ from pyiceberg .table .snapshots import IsolationLevel
543+ from pyiceberg .table .update .validate import (
544+ _validate_added_data_files ,
545+ _validate_deleted_data_files ,
546+ _validate_no_new_delete_files ,
547+ _validate_no_new_deletes_for_data_files ,
548+ )
549+
550+ if self ._parent_snapshot_id is None :
551+ return
552+
553+ table = self ._transaction ._table
554+ parent_snapshot = table .metadata .snapshot_by_id (self ._parent_snapshot_id )
555+ if parent_snapshot is None :
556+ return
557+
558+ isolation_level_str = table .metadata .properties .get (
559+ TableProperties .WRITE_DELETE_ISOLATION_LEVEL , TableProperties .WRITE_ISOLATION_LEVEL_DEFAULT
560+ )
561+ isolation_level = IsolationLevel (isolation_level_str )
562+ conflict_detection_filter = self ._predicate if self ._predicate != AlwaysFalse () else None
563+
564+ if isolation_level == IsolationLevel .SERIALIZABLE :
565+ _validate_added_data_files (table , parent_snapshot , conflict_detection_filter , parent_snapshot )
566+
567+ _validate_no_new_delete_files (table , parent_snapshot , conflict_detection_filter , None , parent_snapshot )
568+ _validate_deleted_data_files (table , parent_snapshot , conflict_detection_filter , parent_snapshot )
569+
570+ if self ._deleted_data_files :
571+ _validate_no_new_deletes_for_data_files (
572+ table , parent_snapshot , conflict_detection_filter , self ._deleted_data_files , parent_snapshot
573+ )
574+
498575
499576class _FastAppendFiles (_SnapshotProducer ["_FastAppendFiles" ]):
500577 def _existing_manifests (self ) -> list [ManifestFile ]:
@@ -666,6 +743,42 @@ def _get_entries(manifest: ManifestFile) -> list[ManifestEntry]:
666743 else :
667744 return []
668745
746+ def _validate_concurrency (self ) -> None :
747+ """Validate that concurrent changes do not conflict with this overwrite."""
748+ from pyiceberg .table import TableProperties
749+ from pyiceberg .table .snapshots import IsolationLevel
750+ from pyiceberg .table .update .validate import (
751+ _validate_added_data_files ,
752+ _validate_deleted_data_files ,
753+ _validate_no_new_delete_files ,
754+ _validate_no_new_deletes_for_data_files ,
755+ )
756+
757+ if self ._parent_snapshot_id is None :
758+ return
759+
760+ table = self ._transaction ._table
761+ parent_snapshot = table .metadata .snapshot_by_id (self ._parent_snapshot_id )
762+ if parent_snapshot is None :
763+ return
764+
765+ isolation_level_str = table .metadata .properties .get (
766+ TableProperties .WRITE_DELETE_ISOLATION_LEVEL , TableProperties .WRITE_ISOLATION_LEVEL_DEFAULT
767+ )
768+ isolation_level = IsolationLevel (isolation_level_str )
769+ conflict_detection_filter = self ._predicate if self ._predicate != AlwaysFalse () else None
770+
771+ if isolation_level == IsolationLevel .SERIALIZABLE :
772+ _validate_added_data_files (table , parent_snapshot , conflict_detection_filter , parent_snapshot )
773+
774+ _validate_no_new_delete_files (table , parent_snapshot , conflict_detection_filter , None , parent_snapshot )
775+ _validate_deleted_data_files (table , parent_snapshot , conflict_detection_filter , parent_snapshot )
776+
777+ if self ._deleted_data_files :
778+ _validate_no_new_deletes_for_data_files (
779+ table , parent_snapshot , conflict_detection_filter , self ._deleted_data_files , parent_snapshot
780+ )
781+
669782
670783class UpdateSnapshot :
671784 _transaction : Transaction
0 commit comments