@@ -1126,8 +1126,6 @@ def mock_commit(
11261126
11271127
11281128class TestUpdateSchemaRetry :
1129- """Tests for UpdateSchema retry behavior (Java-like behavior)."""
1130-
11311129 def test_update_schema_retried_on_conflict (self , catalog : SqlCatalog , schema : Schema ) -> None :
11321130 """Test that UpdateSchema operations are retried on CommitFailedException."""
11331131 from pyiceberg .types import StringType
@@ -1273,3 +1271,196 @@ def mock_commit(
12731271 assert table .schema ().schema_id == 1
12741272 assert len (table .schema ().fields ) == 2
12751273 assert table .schema ().find_field ("new_col" ).field_type == StringType ()
1274+
1275+
1276+ class TestUpdateSortOrderRetry :
1277+ def test_update_sort_order_retried_on_conflict (self , catalog : SqlCatalog , schema : Schema ) -> None :
1278+ """Test that UpdateSortOrder operations are retried on CommitFailedException."""
1279+ from pyiceberg .transforms import IdentityTransform
1280+
1281+ table = catalog .create_table (
1282+ "default.test_sort_order_retry" ,
1283+ schema = schema ,
1284+ properties = {
1285+ TableProperties .COMMIT_NUM_RETRIES : "3" ,
1286+ TableProperties .COMMIT_MIN_RETRY_WAIT_MS : "1" ,
1287+ TableProperties .COMMIT_MAX_RETRY_WAIT_MS : "10" ,
1288+ },
1289+ )
1290+
1291+ original_commit = catalog .commit_table
1292+ commit_count = 0
1293+
1294+ def mock_commit (
1295+ tbl : Table , requirements : tuple [TableRequirement , ...], updates : tuple [TableUpdate , ...]
1296+ ) -> CommitTableResponse :
1297+ nonlocal commit_count
1298+ commit_count += 1
1299+ if commit_count == 1 :
1300+ raise CommitFailedException ("Simulated sort order conflict" )
1301+ return original_commit (tbl , requirements , updates )
1302+
1303+ with patch .object (catalog , "commit_table" , side_effect = mock_commit ):
1304+ with table .update_sort_order () as update_sort_order :
1305+ update_sort_order .asc ("id" , IdentityTransform ())
1306+
1307+ assert commit_count == 2
1308+
1309+ # Verify sort order was updated
1310+ table .refresh ()
1311+ sort_order = table .sort_order ()
1312+ assert sort_order .order_id == 1
1313+ assert len (sort_order .fields ) == 1
1314+ assert sort_order .fields [0 ].source_id == 1 # "id" column
1315+
1316+ def test_update_sort_order_resolves_conflict_on_retry (self , catalog : SqlCatalog , schema : Schema ) -> None :
1317+ """Test that sort order update can resolve conflicts via retry."""
1318+ from pyiceberg .table .sorting import SortDirection
1319+ from pyiceberg .transforms import IdentityTransform
1320+
1321+ table = catalog .create_table (
1322+ "default.test_sort_order_conflict_resolved" ,
1323+ schema = schema ,
1324+ properties = {
1325+ TableProperties .COMMIT_NUM_RETRIES : "5" ,
1326+ TableProperties .COMMIT_MIN_RETRY_WAIT_MS : "1" ,
1327+ TableProperties .COMMIT_MAX_RETRY_WAIT_MS : "10" ,
1328+ },
1329+ )
1330+
1331+ with table .update_sort_order () as update_sort_order :
1332+ update_sort_order .asc ("id" , IdentityTransform ())
1333+
1334+ table2 = catalog .load_table ("default.test_sort_order_conflict_resolved" )
1335+ with table2 .update_sort_order () as update_sort_order2 :
1336+ update_sort_order2 .desc ("id" , IdentityTransform ())
1337+
1338+ assert table .sort_order ().order_id == 1
1339+ assert table2 .sort_order ().order_id == 2
1340+
1341+ original_commit = catalog .commit_table
1342+ commit_count = 0
1343+
1344+ def mock_commit (
1345+ tbl : Table , requirements : tuple [TableRequirement , ...], updates : tuple [TableUpdate , ...]
1346+ ) -> CommitTableResponse :
1347+ nonlocal commit_count
1348+ commit_count += 1
1349+ return original_commit (tbl , requirements , updates )
1350+
1351+ with patch .object (catalog , "commit_table" , side_effect = mock_commit ):
1352+ with table .update_sort_order () as update_sort_order :
1353+ update_sort_order .asc ("id" , IdentityTransform ())
1354+
1355+ assert commit_count == 2
1356+
1357+ table .refresh ()
1358+ sort_order = table .sort_order ()
1359+ assert sort_order .order_id == 1 # Reused existing order with same fields
1360+ assert len (sort_order .fields ) == 1
1361+ assert sort_order .fields [0 ].direction == SortDirection .ASC
1362+
1363+ def test_transaction_with_sort_order_change_and_append_retries (
1364+ self , catalog : SqlCatalog , schema : Schema , arrow_table : pa .Table
1365+ ) -> None :
1366+ """Test that a transaction with sort order change and append handles retry correctly."""
1367+ from pyiceberg .transforms import IdentityTransform
1368+
1369+ table = catalog .create_table (
1370+ "default.test_transaction_sort_order_and_append" ,
1371+ schema = schema ,
1372+ properties = {
1373+ TableProperties .COMMIT_NUM_RETRIES : "3" ,
1374+ TableProperties .COMMIT_MIN_RETRY_WAIT_MS : "1" ,
1375+ TableProperties .COMMIT_MAX_RETRY_WAIT_MS : "10" ,
1376+ },
1377+ )
1378+
1379+ original_commit = catalog .commit_table
1380+ commit_count = 0
1381+ captured_updates : list [tuple [TableUpdate , ...]] = []
1382+
1383+ def mock_commit (
1384+ tbl : Table , requirements : tuple [TableRequirement , ...], updates : tuple [TableUpdate , ...]
1385+ ) -> CommitTableResponse :
1386+ nonlocal commit_count
1387+ commit_count += 1
1388+ captured_updates .append (updates )
1389+ if commit_count == 1 :
1390+ raise CommitFailedException ("Simulated conflict" )
1391+ return original_commit (tbl , requirements , updates )
1392+
1393+ with patch .object (catalog , "commit_table" , side_effect = mock_commit ):
1394+ with table .transaction () as txn :
1395+ with txn .update_sort_order () as update_sort_order :
1396+ update_sort_order .asc ("id" , IdentityTransform ())
1397+ txn .append (arrow_table )
1398+
1399+ assert commit_count == 2
1400+
1401+ first_attempt_update_types = [type (u ).__name__ for u in captured_updates [0 ]]
1402+ assert "AddSortOrderUpdate" in first_attempt_update_types
1403+ assert "AddSnapshotUpdate" in first_attempt_update_types
1404+
1405+ retry_attempt_update_types = [type (u ).__name__ for u in captured_updates [1 ]]
1406+ assert "AddSortOrderUpdate" in retry_attempt_update_types
1407+ assert "AddSnapshotUpdate" in retry_attempt_update_types
1408+
1409+ assert len (table .scan ().to_arrow ()) == 3
1410+
1411+ sort_order = table .sort_order ()
1412+ assert sort_order .order_id == 1
1413+ assert len (sort_order .fields ) == 1
1414+ assert sort_order .fields [0 ].source_id == 1 # "id" column
1415+
1416+ def test_sort_order_column_name_re_resolved_on_retry (self , catalog : SqlCatalog , schema : Schema ) -> None :
1417+ """Test that column names are re-resolved from refreshed schema on retry.
1418+
1419+ This ensures that if the schema changes between retries (e.g., column ID changes),
1420+ the sort order will use the correct field ID from the refreshed schema.
1421+ """
1422+ from pyiceberg .transforms import IdentityTransform
1423+
1424+ table = catalog .create_table (
1425+ "default.test_sort_order_column_re_resolved" ,
1426+ schema = schema ,
1427+ properties = {
1428+ TableProperties .COMMIT_NUM_RETRIES : "3" ,
1429+ TableProperties .COMMIT_MIN_RETRY_WAIT_MS : "1" ,
1430+ TableProperties .COMMIT_MAX_RETRY_WAIT_MS : "10" ,
1431+ },
1432+ )
1433+
1434+ original_commit = catalog .commit_table
1435+ commit_count = 0
1436+ captured_sort_fields : list [list [int ]] = []
1437+
1438+ def mock_commit (
1439+ tbl : Table , requirements : tuple [TableRequirement , ...], updates : tuple [TableUpdate , ...]
1440+ ) -> CommitTableResponse :
1441+ nonlocal commit_count
1442+ commit_count += 1
1443+
1444+ # Extract sort field source IDs from updates
1445+ from pyiceberg .table .update import AddSortOrderUpdate
1446+
1447+ for update in updates :
1448+ if isinstance (update , AddSortOrderUpdate ):
1449+ source_ids = [f .source_id for f in update .sort_order .fields ]
1450+ captured_sort_fields .append (source_ids )
1451+
1452+ if commit_count == 1 :
1453+ raise CommitFailedException ("Simulated conflict" )
1454+ return original_commit (tbl , requirements , updates )
1455+
1456+ with patch .object (catalog , "commit_table" , side_effect = mock_commit ):
1457+ with table .update_sort_order () as update_sort_order :
1458+ update_sort_order .asc ("id" , IdentityTransform ())
1459+
1460+ assert commit_count == 2
1461+ assert len (captured_sort_fields ) == 2
1462+
1463+ # Both attempts should resolve "id" to the same source_id (1)
1464+ # This verifies the column name is being re-resolved correctly
1465+ assert captured_sort_fields [0 ] == [1 ] # First attempt
1466+ assert captured_sort_fields [1 ] == [1 ] # Retry attempt
0 commit comments