2222from abc import abstractmethod
2323from collections import defaultdict
2424from collections .abc import Callable
25+ from dataclasses import dataclass
2526from datetime import datetime
2627from functools import cached_property
2728from typing import TYPE_CHECKING , Generic
@@ -95,6 +96,18 @@ def _new_manifest_list_file_name(snapshot_id: int, attempt: int, commit_uuid: uu
9596 return f"snap-{ snapshot_id } -{ attempt } -{ commit_uuid } .avro"
9697
9798
99+ @dataclass
100+ class CommitWindow :
101+ """Tracks the commit range to validate against during retry.
102+
103+ starting_snapshot_id: The snapshot when the operation began (fixed across retries).
104+ catalog_head_snapshot_id: The catalog's latest HEAD snapshot (updated on each retry).
105+ """
106+
107+ starting_snapshot_id : int | None
108+ catalog_head_snapshot_id : int | None
109+
110+
98111class _SnapshotProducer (UpdateTableMetadata [U ], Generic [U ]):
99112 commit_uuid : uuid .UUID
100113 _io : FileIO
@@ -109,6 +122,7 @@ class _SnapshotProducer(UpdateTableMetadata[U], Generic[U]):
109122 _target_branch : str | None
110123 _predicate : BooleanExpression
111124 _case_sensitive : bool
125+ _commit_window : CommitWindow | None
112126 _written_manifests : list [str ]
113127 _uncommitted_manifests : list [str ]
114128
@@ -144,6 +158,7 @@ def __init__(
144158 self ._starting_snapshot_id = self ._parent_snapshot_id
145159 self ._predicate = AlwaysFalse ()
146160 self ._case_sensitive = True
161+ self ._commit_window = None
147162 self ._isolation_level_property : str = TableProperties .WRITE_DELETE_ISOLATION_LEVEL
148163
149164 def _validate_target_branch (self , branch : str | None ) -> str | None :
@@ -407,8 +422,10 @@ def _refresh_for_retry(self) -> None:
407422 def _validate_concurrency (self ) -> None :
408423 """Validate that concurrent changes do not conflict with this operation.
409424
410- Checks isolation level and uses the conflict detection filter to determine
411- whether concurrent commits introduced conflicting data or delete files.
425+ Uses the CommitWindow to determine which catalog commits to validate against.
426+ The window spans from starting_snapshot (when the operation began) to the
427+ catalog HEAD (latest committed snapshot), covering all external concurrent commits.
428+
412429 Subclasses that do not require validation (e.g. fast append) should override
413430 with a no-op.
414431 """
@@ -421,8 +438,11 @@ def _validate_concurrency(self) -> None:
421438 _validate_no_new_deletes_for_data_files ,
422439 )
423440
424- parent_snapshot = self ._resolve_parent_snapshot ()
425- if parent_snapshot is None :
441+ if self ._commit_window is None :
442+ return
443+
444+ catalog_head = self ._resolve_catalog_head_snapshot ()
445+ if catalog_head is None :
426446 return
427447
428448 starting_snapshot = self ._resolve_starting_snapshot ()
@@ -435,16 +455,27 @@ def _validate_concurrency(self) -> None:
435455 conflict_detection_filter = self ._predicate if self ._predicate != AlwaysFalse () else None
436456
437457 if isolation_level == IsolationLevel .SERIALIZABLE :
438- _validate_added_data_files (table , parent_snapshot , conflict_detection_filter , starting_snapshot )
458+ _validate_added_data_files (table , catalog_head , conflict_detection_filter , starting_snapshot )
439459
440460 if conflict_detection_filter is not None :
441- _validate_no_new_delete_files (table , parent_snapshot , conflict_detection_filter , None , starting_snapshot )
442- _validate_deleted_data_files (table , parent_snapshot , conflict_detection_filter , starting_snapshot )
461+ _validate_no_new_delete_files (table , catalog_head , conflict_detection_filter , None , starting_snapshot )
462+ _validate_deleted_data_files (table , catalog_head , conflict_detection_filter , starting_snapshot )
443463
444464 if self ._deleted_data_files :
445465 _validate_no_new_deletes_for_data_files (
446- table , parent_snapshot , conflict_detection_filter , self ._deleted_data_files , starting_snapshot
466+ table , catalog_head , conflict_detection_filter , self ._deleted_data_files , starting_snapshot
467+ )
468+
469+ def _resolve_catalog_head_snapshot (self ) -> Snapshot | None :
470+ """Resolve the catalog HEAD snapshot from the CommitWindow."""
471+ if self ._commit_window is None or self ._commit_window .catalog_head_snapshot_id is None :
472+ return None
473+ snapshot = self ._transaction ._table .metadata .snapshot_by_id (self ._commit_window .catalog_head_snapshot_id )
474+ if snapshot is None :
475+ raise ValidationException (
476+ f"Cannot find catalog head snapshot { self ._commit_window .catalog_head_snapshot_id } in table metadata"
447477 )
478+ return snapshot
448479
449480 def _resolve_parent_snapshot (self ) -> Snapshot | None :
450481 """Resolve parent snapshot, raising ValidationException if ID is set but snapshot is missing."""
@@ -456,8 +487,8 @@ def _resolve_parent_snapshot(self) -> Snapshot | None:
456487 return snapshot
457488
458489 def _resolve_starting_snapshot (self ) -> Snapshot :
459- """Resolve starting snapshot for the conflict detection window."""
460- starting_id = self ._starting_snapshot_id if self ._starting_snapshot_id is not None else self ._parent_snapshot_id
490+ """Resolve starting snapshot for the conflict detection window from the CommitWindow ."""
491+ starting_id = self ._commit_window . starting_snapshot_id if self ._commit_window else self ._starting_snapshot_id
461492 if starting_id is None :
462493 raise ValidationException ("Cannot resolve starting snapshot: both starting and parent snapshot IDs are None" )
463494 snapshot = self ._transaction ._table .metadata .snapshot_by_id (starting_id )
0 commit comments