This is an automated email from the ASF dual-hosted git repository.
XiaoHongbo-Hope pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new a70912b5fc [python] Avoid deleting files during distributed commit
failures (#9269)
a70912b5fc is described below
commit a70912b5fc5b1506e0a927aca5a19f42b2627e01
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Aug 17 20:45:13 2026 +0800
[python] Avoid deleting files during distributed commit failures (#9269)
## What changed
- Remove `CommitConflictError`; commit conflicts now propagate as
ordinary `RuntimeError` failures.
- Never automatically abort prepared files when a table commit raises.
- Stop Ray's driver-side `on_write_failed()` callback from deleting
files returned by completed write tasks.
- Remove the pending-commit-message cleanup state from the Ray sink.
- Keep worker-local cleanup before a `CommitMessage` is returned,
explicit `TableCommit.abort()`, and pre-commit local cleanup paths.
## Why
Ray task, job, or scheduler retries can replay a previously returned
`CommitMessage`. If another failure path automatically aborts that
message first, the later attempt can commit a snapshot whose manifest
references an already deleted file. Commit retries do not verify that
every referenced data file still exists.
Post-commit callbacks create the same ambiguity: an exception can
propagate after the snapshot is already committed, so exception type is
not a safe ownership signal for deleting files.
This change deliberately prefers possible orphan files over deleting
data that another attempt may commit or a snapshot may already
reference. Orphan cleanup can reclaim those files later using table
metadata.
## Validation
- `124 passed, 15 subtests passed` across Ray sink, commit callback,
dynamic bucket, table write, and file-store commit tests.
- `py_compile` passed for all changed production files.
- `git diff --check` passed.
---
paimon-python/pypaimon/daft/daft_datasink.py | 12 +---
.../pypaimon/ray/data_evolution_merge_into.py | 40 ++---------
paimon-python/pypaimon/ray/update_by_row_id.py | 42 ++---------
.../tests/daft/daft_pk_distributed_write_test.py | 8 +++
paimon-python/pypaimon/tests/ray_sink_test.py | 83 ++++++++++------------
.../pypaimon/tests/ray_update_by_row_id_test.py | 6 +-
.../pypaimon/tests/table_merge_into_test.py | 21 +++---
.../pypaimon/tests/write/commit_callback_test.py | 47 ++++++++++++
.../pypaimon/tests/write/dynamic_bucket_test.py | 11 ++-
.../pypaimon/tests/write/table_write_test.py | 16 ++---
.../pypaimon/write/commit/conflict_detection.py | 4 --
paimon-python/pypaimon/write/file_store_commit.py | 19 +----
paimon-python/pypaimon/write/ray_datasink.py | 35 +--------
paimon-python/pypaimon/write/table_commit.py | 65 +++++++----------
14 files changed, 159 insertions(+), 250 deletions(-)
diff --git a/paimon-python/pypaimon/daft/daft_datasink.py
b/paimon-python/pypaimon/daft/daft_datasink.py
index c21767b0d2..1486832a26 100644
--- a/paimon-python/pypaimon/daft/daft_datasink.py
+++ b/paimon-python/pypaimon/daft/daft_datasink.py
@@ -289,23 +289,15 @@ class PaimonDataSink(DataSink[list[Any]]):
def finalize(self, write_results: list[WriteResult[list[Any]]]) ->
MicroPartition:
all_commit_messages = [msg for wr in write_results for msg in
wr.result]
- table_commit = self._write_builder.new_commit()
non_empty_workers = sum(
any(not message.is_empty() for message in result.result)
for result in write_results
)
primary_key_error = self._primary_key_write_error(non_empty_workers)
if primary_key_error is not None:
- try:
- table_commit.abort(all_commit_messages)
- except Exception:
- logger.warning(
- "Failed to abort uncommitted direct Daft PK files.",
- exc_info=True,
- )
- finally:
- table_commit.close()
raise ValueError(primary_key_error)
+
+ table_commit = self._write_builder.new_commit()
try:
table_commit.commit(all_commit_messages)
finally:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index d43ac21f36..b7ba555822 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -375,8 +375,7 @@ def _execute_and_commit(
ray_remote_args, concurrency,
):
collect_action_row_ids = update_ds is not None and delete_ds is not None
- pending_msgs: list = []
- commit_started = False
+ commit_messages: list = []
update_msgs: list = []
num_updated = 0
@@ -398,7 +397,7 @@ def _execute_and_commit(
),
collect_row_ids=collect_action_row_ids,
)
- pending_msgs.extend(update_msgs)
+ commit_messages.extend(update_msgs)
if delete_ds is not None:
delete_msgs, num_deleted, delete_row_ids =
distributed_delete_apply(
@@ -411,7 +410,7 @@ def _execute_and_commit(
),
collect_row_ids=collect_action_row_ids,
)
- pending_msgs.extend(delete_msgs)
+ commit_messages.extend(delete_msgs)
if collect_action_row_ids:
_validate_disjoint_action_row_ids(update_row_ids, delete_row_ids)
@@ -421,7 +420,7 @@ def _execute_and_commit(
insert_ds, table,
ray_remote_args=ray_remote_args, concurrency=concurrency,
)
- pending_msgs.extend(insert_msgs)
+ commit_messages.extend(insert_msgs)
num_inserted = sum(
f.row_count
for m in insert_msgs
@@ -429,12 +428,11 @@ def _execute_and_commit(
if not DataFileMeta.is_blob_file(f.file_name)
)
- all_msgs: list = list(pending_msgs)
+ all_msgs: list = list(commit_messages)
if all_msgs:
table_commit = None
try:
table_commit = table.new_batch_write_builder().new_commit()
- commit_started = True
table_commit.commit(all_msgs)
finally:
if table_commit is not None:
@@ -447,8 +445,6 @@ def _execute_and_commit(
exc_info=close_error,
)
except Exception as e:
- if not commit_started:
- _abort_pending_merge_messages(table, pending_msgs)
_reraise_inner(e)
# num_matched = rows that passed a matched condition and changed
@@ -459,32 +455,6 @@ def _execute_and_commit(
}
-def _abort_pending_merge_messages(table, commit_messages) -> None:
- if not commit_messages:
- return
-
- table_commit = None
- try:
- table_commit = table.new_batch_write_builder().new_commit()
- table_commit.abort(commit_messages)
- except Exception as abort_error:
- logger.warning(
- "Failed to abort pending merge_into commit messages: %s",
- abort_error,
- exc_info=abort_error,
- )
- finally:
- if table_commit is not None:
- try:
- table_commit.close()
- except Exception as close_error:
- logger.warning(
- "Failed to close merge_into abort commit: %s",
- close_error,
- exc_info=close_error,
- )
-
-
def _normalize_on(on: OnSpec) -> Tuple[List[str], List[str]]:
if isinstance(on, Mapping):
target_cols = list(on.keys())
diff --git a/paimon-python/pypaimon/ray/update_by_row_id.py
b/paimon-python/pypaimon/ray/update_by_row_id.py
index 41b968c4fd..bc1b437e8c 100644
--- a/paimon-python/pypaimon/ray/update_by_row_id.py
+++ b/paimon-python/pypaimon/ray/update_by_row_id.py
@@ -156,52 +156,20 @@ def update_by_row_id(
def _commit_update_messages(table, commit_messages) -> None:
- pending_msgs: list = list(commit_messages)
- commit_started = False
-
- try:
- table_commit = None
- try:
- table_commit = table.new_batch_write_builder().new_commit()
- commit_started = True
- table_commit.commit(pending_msgs)
- finally:
- if table_commit is not None:
- try:
- table_commit.close()
- except Exception as close_error:
- logger.warning(
- "Failed to close update_by_row_id commit: %s",
- close_error,
- exc_info=close_error,
- )
- except Exception as e:
- if not commit_started:
- _abort_pending_update_messages(table, pending_msgs)
- _reraise_inner(e)
-
-
-def _abort_pending_update_messages(table, commit_messages) -> None:
- if not commit_messages:
- return
-
+ messages = list(commit_messages)
table_commit = None
try:
table_commit = table.new_batch_write_builder().new_commit()
- table_commit.abort(commit_messages)
- except Exception as abort_error:
- logger.warning(
- "Failed to abort pending update_by_row_id commit messages: %s",
- abort_error,
- exc_info=abort_error,
- )
+ table_commit.commit(messages)
+ except Exception as e:
+ _reraise_inner(e)
finally:
if table_commit is not None:
try:
table_commit.close()
except Exception as close_error:
logger.warning(
- "Failed to close update_by_row_id abort commit: %s",
+ "Failed to close update_by_row_id commit: %s",
close_error,
exc_info=close_error,
)
diff --git
a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
index 1c2ff559b9..c96ad3c20c 100644
--- a/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
+++ b/paimon-python/pypaimon/tests/daft/daft_pk_distributed_write_test.py
@@ -125,6 +125,13 @@ def
test_bare_datasink_rejects_multiple_primary_key_write_tasks(catalog):
pa.table({"id": [value], "value": [f"v-{value}"]})
)
results.extend(sink.write(iter([micropartition])))
+ staged_paths = [
+ str(data_file.external_path or data_file.file_path)
+ for result in results
+ for message in result.result
+ for data_file in message.new_files
+ ]
+ assert staged_paths
with pytest.raises(
ValueError, match="require a single non-empty Daft write task"
@@ -132,6 +139,7 @@ def
test_bare_datasink_rejects_multiple_primary_key_write_tasks(catalog):
sink.finalize(results)
assert table.snapshot_manager().get_latest_snapshot() is None
+ assert all(table.file_io.exists(path) for path in staged_paths)
def test_bare_datasink_supports_single_task_dynamic_bucket(catalog):
diff --git a/paimon-python/pypaimon/tests/ray_sink_test.py
b/paimon-python/pypaimon/tests/ray_sink_test.py
index e6a4029b81..41e7365e5f 100644
--- a/paimon-python/pypaimon/tests/ray_sink_test.py
+++ b/paimon-python/pypaimon/tests/ray_sink_test.py
@@ -496,7 +496,6 @@ class RaySinkTest(unittest.TestCase):
datasink._writer_builder.new_commit = mock_new_commit
with self.assertRaises(Exception):
datasink.on_write_complete(write_result)
- self.assertEqual(len(datasink._pending_commit_messages), 1)
def test_on_write_complete_without_on_write_start(self):
from ray.data.datasource.datasink import WriteResult
@@ -580,34 +579,15 @@ class RaySinkTest(unittest.TestCase):
)
def test_on_write_failed(self):
- # Test without pending messages (on_write_complete() never called)
datasink = PaimonDatasink(self.table, overwrite=False)
datasink.on_write_start()
- self.assertEqual(datasink._pending_commit_messages, [])
- error = Exception("Write job failed")
- datasink.on_write_failed(error) # Should not raise exception
-
- # Test with pending messages (on_write_complete() was called but
failed)
- datasink = PaimonDatasink(self.table, overwrite=False)
- datasink.on_write_start()
- commit_msg1 = Mock(spec=CommitMessage)
- commit_msg2 = Mock(spec=CommitMessage)
- datasink._pending_commit_messages = [commit_msg1, commit_msg2]
-
- mock_commit = Mock()
- datasink._writer_builder.new_commit = Mock(return_value=mock_commit)
+ datasink._writer_builder.new_commit = Mock()
error = Exception("Write job failed")
datasink.on_write_failed(error)
- mock_commit.abort.assert_called_once()
- abort_args = mock_commit.abort.call_args[0][0]
- self.assertEqual(len(abort_args), 2)
- self.assertEqual(abort_args[0], commit_msg1)
- self.assertEqual(abort_args[1], commit_msg2)
- mock_commit.close.assert_called_once()
- self.assertEqual(datasink._pending_commit_messages, [])
+ datasink._writer_builder.new_commit.assert_not_called()
- def test_consume_write_results_stages_messages_before_late_failure(self):
+ def test_consume_write_results_reports_late_failure(self):
import pickle
message_col = '__messages__'
@@ -629,9 +609,6 @@ class RaySinkTest(unittest.TestCase):
FailingResults(), coordinator, message_col
)
- coordinator.add_pending_commit_messages.assert_called_once_with(
- ['first']
- )
coordinator.on_write_complete.assert_not_called()
coordinator.on_write_failed.assert_called_once()
@@ -660,29 +637,47 @@ class RaySinkTest(unittest.TestCase):
results, coordinator, message_col, error_col
)
- self.assertEqual(
- [call.args[0] for call in
- coordinator.add_pending_commit_messages.call_args_list],
- [['first'], [], ['last']],
- )
coordinator.on_write_complete.assert_not_called()
coordinator.on_write_failed.assert_called_once()
- # Test abort failure handling (should not raise exception)
- datasink = PaimonDatasink(self.table, overwrite=False)
- datasink.on_write_start()
- commit_msg1 = Mock(spec=CommitMessage)
- datasink._pending_commit_messages = [commit_msg1]
+ def test_consume_write_results_failure_preserves_completed_files(self):
+ import pickle
- mock_commit = Mock()
- mock_commit.abort.side_effect = Exception("Abort failed")
- datasink._writer_builder.new_commit = Mock(return_value=mock_commit)
- error = Exception("Write job failed")
- datasink.on_write_failed(error)
+ writer = self.table.new_batch_write_builder().new_write()
+ writer.write_arrow(pa.Table.from_pydict({
+ 'id': [1],
+ 'name': ['Alice'],
+ 'value': [1.1],
+ }, schema=self.pk_pa_schema))
+ messages = writer.prepare_commit()
+ writer.close()
+ paths = [
+ file.external_path or file.file_path
+ for message in messages
+ for file in message.new_files
+ ]
- mock_commit.abort.assert_called_once()
- mock_commit.close.assert_called_once()
- self.assertEqual(datasink._pending_commit_messages, [])
+ message_col = '__messages__'
+ error_col = '__errors__'
+ results = Mock()
+ results.iter_batches.return_value = iter([
+ pa.table({
+ message_col: pa.array([
+ pickle.dumps(messages),
+ pickle.dumps([]),
+ ], type=pa.binary()),
+ error_col: pa.array([None, 'worker failure'],
type=pa.string()),
+ }),
+ ])
+ coordinator = PaimonDatasink(self.table, overwrite=False)
+ coordinator.on_write_start()
+
+ with self.assertRaisesRegex(RuntimeError, 'worker failure'):
+ _consume_write_results(
+ results, coordinator, message_col, error_col
+ )
+
+ self.assertTrue(all(self.table.file_io.exists(path) for path in paths))
if __name__ == '__main__':
diff --git a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
index 801c35218a..e2bda92237 100644
--- a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
@@ -175,7 +175,7 @@ class RayUpdateByRowIdTest(unittest.TestCase):
update_by_row_id(target, src, self.catalog_options,
update_cols=["age"])
self.assertEqual(captured["base_snapshot_id"], expected_sid)
- def test_new_commit_failure_aborts_pending_messages(self):
+ def test_new_commit_failure_preserves_pending_messages(self):
err = RuntimeError("new_commit failed")
recorder = {}
@@ -186,8 +186,8 @@ class RayUpdateByRowIdTest(unittest.TestCase):
)
self.assertEqual(recorder["commit_calls"], 0)
- self.assertEqual(recorder["abort_calls"], 1)
- self.assertEqual(recorder["abort_msgs"], recorder["msgs"])
+ self.assertEqual(recorder["abort_calls"], 0)
+ self.assertEqual(recorder["new_commit_calls"], 1)
def test_commit_failure_does_not_abort_after_commit_started(self):
err = RuntimeError("commit failed")
diff --git a/paimon-python/pypaimon/tests/table_merge_into_test.py
b/paimon-python/pypaimon/tests/table_merge_into_test.py
index 5ad3a405f3..f69ddfe8b5 100644
--- a/paimon-python/pypaimon/tests/table_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/table_merge_into_test.py
@@ -102,7 +102,7 @@ class TableMergeIntoTest(BatchModeMixin,
DataEvolutionTestBase, unittest.TestCas
update_msg = object()
delete_msg = object()
- table, table_commit = self._mock_ray_commit_table()
+ table, _ = self._mock_ray_commit_table()
with patch.object(
ray_merge,
@@ -125,14 +125,13 @@ class TableMergeIntoTest(BatchModeMixin,
DataEvolutionTestBase, unittest.TestCas
ray_remote_args=None,
concurrency=None,
)
- table_commit.abort.assert_called_once_with([update_msg, delete_msg])
- table_commit.close.assert_called_once_with()
+ table.new_batch_write_builder.assert_not_called()
- def
test_ray_execute_aborts_prepared_messages_on_later_branch_failure(self):
+ def test_ray_execute_preserves_messages_on_later_branch_failure(self):
import pypaimon.ray.data_evolution_merge_into as ray_merge
update_msg = object()
- table, table_commit = self._mock_ray_commit_table()
+ table, _ = self._mock_ray_commit_table()
with patch.object(
ray_merge,
@@ -155,15 +154,13 @@ class TableMergeIntoTest(BatchModeMixin,
DataEvolutionTestBase, unittest.TestCas
ray_remote_args=None,
concurrency=None,
)
- table_commit.abort.assert_called_once_with([update_msg])
- table_commit.close.assert_called_once_with()
- table_commit.commit.assert_not_called()
+ table.new_batch_write_builder.assert_not_called()
- def test_ray_execute_aborts_prepared_messages_on_insert_failure(self):
+ def test_ray_execute_preserves_messages_on_insert_failure(self):
import pypaimon.ray.data_evolution_merge_into as ray_merge
update_msg = object()
- table, table_commit = self._mock_ray_commit_table()
+ table, _ = self._mock_ray_commit_table()
with patch.object(
ray_merge,
@@ -186,9 +183,7 @@ class TableMergeIntoTest(BatchModeMixin,
DataEvolutionTestBase, unittest.TestCas
ray_remote_args=None,
concurrency=None,
)
- table_commit.abort.assert_called_once_with([update_msg])
- table_commit.close.assert_called_once_with()
- table_commit.commit.assert_not_called()
+ table.new_batch_write_builder.assert_not_called()
def test_ray_execute_does_not_abort_after_commit_starts(self):
import pypaimon.ray.data_evolution_merge_into as ray_merge
diff --git a/paimon-python/pypaimon/tests/write/commit_callback_test.py
b/paimon-python/pypaimon/tests/write/commit_callback_test.py
index 4e02bf32f6..036031edef 100644
--- a/paimon-python/pypaimon/tests/write/commit_callback_test.py
+++ b/paimon-python/pypaimon/tests/write/commit_callback_test.py
@@ -153,6 +153,53 @@ class CommitCallbackTest(unittest.TestCase):
table_commit.close()
self.assertTrue(callback.closed)
+ def test_callback_error_after_commit_keeps_committed_files(self):
+ table = self._create_table('test_callback_error_after_commit')
+ write_builder = table.new_batch_write_builder()
+ table_write = write_builder.new_write()
+ table_commit = write_builder.new_commit()
+
+ callback_error = RuntimeError('callback failed after commit')
+
+ class FailingCallback(CommitCallback):
+
+ def call(self, context: CommitCallbackContext) -> None:
+ raise callback_error
+
+ table_commit.add_commit_callback(FailingCallback())
+ try:
+ table_write.write_arrow(pa.Table.from_pydict({
+ 'id': [1],
+ 'name': ['committed'],
+ 'dt': ['p1'],
+ }, schema=self.pa_schema))
+ messages = table_write.prepare_commit()
+ data_paths = [
+ file.external_path or file.file_path
+ for message in messages
+ for file in message.new_files
+ ]
+
+ with self.assertRaises(RuntimeError) as context:
+ table_commit.commit(messages)
+
+ latest_snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual(1, latest_snapshot.id)
+ self.assertTrue(all(
+ table.file_io.exists(path) for path in data_paths))
+
+ read_builder = table.new_read_builder()
+ actual = read_builder.new_read().to_arrow(
+ read_builder.new_scan().plan().splits())
+ self.assertEqual(
+ {'id': [1], 'name': ['committed'], 'dt': ['p1']},
+ actual.to_pydict(),
+ )
+ self.assertIs(callback_error, context.exception)
+ finally:
+ table_write.close()
+ table_commit.close()
+
def test_callback_not_invoked_when_no_data(self):
table = self._create_table('test_callback_no_data')
write_builder = table.new_batch_write_builder()
diff --git a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
index f7714f5e99..ffe6c88319 100644
--- a/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
+++ b/paimon-python/pypaimon/tests/write/dynamic_bucket_test.py
@@ -37,7 +37,6 @@ from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.generic_row import GenericRow
-from pypaimon.write.commit.conflict_detection import CommitConflictError
from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor
@@ -455,7 +454,7 @@ class DynamicBucketTest(unittest.TestCase):
commit2.commit(messages2)
self.assertTrue(all(
- not table.file_io.exists(path)
+ table.file_io.exists(path)
for path in stale_data_paths + stale_index_paths
))
@@ -554,7 +553,7 @@ class DynamicBucketTest(unittest.TestCase):
commit2.commit(messages2)
self.assertTrue(all(
- not table.file_io.exists(path) for path in stale_paths
+ table.file_io.exists(path) for path in stale_paths
))
writer1.close()
writer2.close()
@@ -603,7 +602,7 @@ class DynamicBucketTest(unittest.TestCase):
stale_writer.close()
stale_commit.close()
- def test_retry_then_hash_index_conflict_aborts_prepared_files(self):
+ def test_retry_then_hash_index_conflict_preserves_prepared_files(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'retry_hash_conflict')
writer, commit, messages = self._prepare_indexed_write(table, [1])
@@ -643,13 +642,13 @@ class DynamicBucketTest(unittest.TestCase):
'_commit_retry_wait',
):
with self.assertRaisesRegex(
- CommitConflictError, 'HASH index assignment conflict'
+ RuntimeError, 'HASH index assignment conflict'
):
commit.commit(messages)
self.assertEqual(1, calls)
self.assertTrue(all(
- not table.file_io.exists(path) for path in prepared_paths
+ table.file_io.exists(path) for path in prepared_paths
))
writer.close()
commit.close()
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index a640b8d31f..e02a688cf9 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -1174,8 +1174,6 @@ class TableWriteTest(unittest.TestCase):
self.assertEqual(1, overwrite_plan.num_buckets(('p',)))
def test_postpone_worker_bucket_plan_mismatch_fails_commit(self):
- from pypaimon.write.commit.conflict_detection import
CommitConflictError
-
table = self._create_postpone_table(
'default.test_postpone_worker_plan_mismatch',
pa_schema=self.postpone_pa_schema,
@@ -1202,7 +1200,7 @@ class TableWriteTest(unittest.TestCase):
large_messages = large_write.prepare_commit()
self.assertEqual({1}, {m.total_buckets for m in small_messages})
self.assertEqual({3}, {m.total_buckets for m in large_messages})
- with self.assertRaisesRegex(CommitConflictError, 'Total buckets'):
+ with self.assertRaisesRegex(RuntimeError, 'Total buckets'):
commit.commit(small_messages + large_messages)
finally:
small_write.close()
@@ -1210,8 +1208,6 @@ class TableWriteTest(unittest.TestCase):
commit.close()
def test_postpone_overwrite_bucket_plan_mismatch_fails_commit(self):
- from pypaimon.write.commit.conflict_detection import
CommitConflictError
-
table = self._create_postpone_table(
'default.test_postpone_overwrite_plan_mismatch',
pa_schema=self.postpone_pa_schema,
@@ -1245,10 +1241,10 @@ class TableWriteTest(unittest.TestCase):
for file in message.new_files
]
- with self.assertRaisesRegex(CommitConflictError, 'Total buckets'):
+ with self.assertRaisesRegex(RuntimeError, 'Total buckets'):
commit.commit(messages)
self.assertTrue(all(
- not table.file_io.exists(path) for path in paths))
+ table.file_io.exists(path) for path in paths))
finally:
small_write.close()
large_write.close()
@@ -1468,8 +1464,6 @@ class TableWriteTest(unittest.TestCase):
)
def test_postpone_concurrent_new_partition_bucket_num_conflict(self):
- from pypaimon.write.commit.conflict_detection import
CommitConflictError
-
table_two_buckets = self._create_postpone_table(
'default.test_postpone_concurrent_bucket_num',
partition_keys=['dt'],
@@ -1530,11 +1524,11 @@ class TableWriteTest(unittest.TestCase):
commit_three.file_store_commit.snapshot_commit.commit = (
fail_cas_after_concurrent_commit)
- with self.assertRaisesRegex(CommitConflictError, "Total buckets"):
+ with self.assertRaisesRegex(RuntimeError, "Total buckets"):
commit_three.commit(messages_three)
self.assertTrue(concurrent_commit['done'])
self.assertTrue(all(
- not table_three_buckets.file_io.exists(path)
+ table_three_buckets.file_io.exists(path)
for path in losing_paths
))
finally:
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 62606c183a..4e6026f7b6 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -149,10 +149,6 @@ class _WriteRange:
self.field_ids = field_ids
-class CommitConflictError(RuntimeError):
- """A deterministic pre-snapshot conflict which is safe to abort."""
-
-
class RowIdExistenceConflict(RuntimeError):
"""A staged row-id file no longer matches the current base-file layout."""
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index 7e2b69f97a..adcfa79148 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -39,7 +39,6 @@ from pypaimon.table.row.offset_row import OffsetRow
from pypaimon.write.commit.commit_rollback import CommitRollback
from pypaimon.write.commit.commit_scanner import CommitScanner
from pypaimon.write.commit.conflict_detection import (
- CommitConflictError,
ConflictDetection,
RowIdExistenceConflict,
)
@@ -567,8 +566,6 @@ class FileStoreCommit:
)
if commit_result_may_be_uncertain:
raise RuntimeError(error_msg) from
uncertain_commit_exception
- if retry_result is not None and retry_result.exception is None:
- raise CommitConflictError(error_msg)
if retry_result is not None and retry_result.exception:
raise RuntimeError(error_msg) from retry_result.exception
else:
@@ -597,15 +594,12 @@ class FileStoreCommit:
hash_index_base_snapshot is not None
and latest_snapshot_id != hash_index_base_snapshot
):
- conflict = RuntimeError(
+ raise RuntimeError(
"HASH index assignment conflict detected: assigned from "
"snapshot {}, but the latest snapshot is {}.".format(
hash_index_base_snapshot, latest_snapshot_id
)
)
- if not commit_result_may_be_uncertain:
- raise CommitConflictError(str(conflict)) from conflict
- raise conflict
unique_id = uuid.uuid4()
base_manifest_list = f"manifest-list-{unique_id}-0"
@@ -671,13 +665,6 @@ class FileStoreCommit:
# Rolled back: base/snapshot no longer valid; next
attempt
# re-scans from scratch (matches Java
RollbackRetryResult).
return RetryResult(None, conflict_exception)
- if not commit_result_may_be_uncertain:
- raise CommitConflictError(
- str(conflict_exception)
- ) from conflict_exception
- # A previous attempt may have committed despite returning an
- # error. Preserve the generic, uncertain-result semantics so
- # callers do not delete files which a snapshot may reference.
raise conflict_exception
# Apply row tracking logic after conflict detection (matches Java
ordering)
@@ -886,7 +873,7 @@ class FileStoreCommit:
)
)
if non_compaction_conflict is not None:
- raise CommitConflictError(
+ raise RuntimeError(
str(non_compaction_conflict)
) from non_compaction_conflict
@@ -902,7 +889,7 @@ class FileStoreCommit:
commit_entries,
)
except RuntimeError as rewrite_error:
- raise CommitConflictError(
+ raise RuntimeError(
"{} {}".format(conflict_exception, rewrite_error)
) from conflict_exception
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index c87c9cbd9e..e121bd2432 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -82,7 +82,6 @@ class PaimonDatasink(_DatasinkBase):
self._postpone_bucket_plan = postpone_bucket_plan
self._table_name = table.identifier.get_full_name()
self._writer_builder: Optional["WriteBuilder"] = None
- self._pending_commit_messages: List["CommitMessage"] = []
def _is_overwrite(self) -> bool:
return self.overwrite or self.static_partition is not None
@@ -192,11 +191,8 @@ class PaimonDatasink(_DatasinkBase):
msg for msg in all_commit_messages if not msg.is_empty()
]
- self._pending_commit_messages = non_empty_messages
-
if not non_empty_messages and not self._is_overwrite():
logger.info("No data to commit (all commit messages are
empty)")
- self._pending_commit_messages = []
return
# Ray does not call on_write_start when the input has no blocks.
@@ -211,16 +207,12 @@ class PaimonDatasink(_DatasinkBase):
table_commit = self._writer_builder.new_commit()
table_commit.commit(non_empty_messages)
- self._pending_commit_messages = []
-
logger.info(f"Successfully committed write job for table
{self._table_name}")
except Exception as e:
logger.error(
f"Error committing write job for table {self._table_name}:
{e}",
exc_info=e
)
- if table_commit is not None:
- self._pending_commit_messages = []
raise
finally:
if table_commit is not None:
@@ -232,35 +224,13 @@ class PaimonDatasink(_DatasinkBase):
exc_info=e
)
- def add_pending_commit_messages(self, commit_messages) -> None:
- self._pending_commit_messages.extend(
- message for message in commit_messages if not message.is_empty()
- )
-
def on_write_failed(self, error: Exception) -> None:
logger.error(
f"Write job failed for table {self._table_name}. Error: {error}",
exc_info=error
)
-
- if self._pending_commit_messages:
- try:
- table_commit = self._writer_builder.new_commit()
- try:
- table_commit.abort(self._pending_commit_messages)
- logger.info(
- f"Aborted {len(self._pending_commit_messages)} commit
messages "
- f"for table {self._table_name} in on_write_failed()"
- )
- finally:
- table_commit.close()
- except Exception as abort_error:
- logger.error(
- f"Error aborting commit messages in on_write_failed():
{abort_error}",
- exc_info=abort_error
- )
- finally:
- self._pending_commit_messages = []
+ # Do not abort files returned by completed write tasks. Ray or an outer
+ # scheduler may replay those commit messages in another attempt.
def write_paimon_dataset(
@@ -480,7 +450,6 @@ def _consume_write_results(
for blob, error in zip(messages, batch_errors):
commit_messages = pickle.loads(blob)
write_returns.append(commit_messages)
- coordinator.add_pending_commit_messages(commit_messages)
if error is not None:
errors.append(error)
if errors:
diff --git a/paimon-python/pypaimon/write/table_commit.py
b/paimon-python/pypaimon/write/table_commit.py
index a70c52ebd3..3215f7eea7 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -23,7 +23,6 @@ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
logger = logging.getLogger(__name__)
from pypaimon.write.commit_callback import CommitCallback
from pypaimon.write.commit_message import CommitMessage
-from pypaimon.write.commit.conflict_detection import CommitConflictError
from pypaimon.write.file_store_commit import FileStoreCommit
@@ -64,43 +63,33 @@ class TableCommit:
def _commit(self, commit_messages: List[CommitMessage], commit_identifier:
int = BATCH_COMMIT_IDENTIFIER):
non_empty_messages = [msg for msg in commit_messages if not
msg.is_empty()]
- try:
- if self.overwrite_partition is not None:
- # Always call overwrite() even with empty messages, so that
- # FileStoreCommit.overwrite can handle the empty case properly
- # (e.g. static overwrite with empty data should delete the
partition).
- logger.info(
- "Committing overwrite to table %s, %d non-empty messages",
- self.table.identifier, len(non_empty_messages)
- )
- self.file_store_commit.overwrite(
- overwrite_partition=self.overwrite_partition,
- commit_messages=non_empty_messages,
- commit_identifier=commit_identifier
- )
- else:
- if not non_empty_messages:
- return
- logger.info(
- "Committing table %s, %d non-empty messages",
- self.table.identifier, len(non_empty_messages)
- )
- self.file_store_commit.commit(
- commit_messages=non_empty_messages,
- commit_identifier=commit_identifier
- )
- except CommitConflictError:
- # These files are known to be uncommitted. Generic commit failures
- # remain untouched because their success is uncertain.
- try:
- self.file_store_commit.abort(non_empty_messages)
- except Exception:
- logger.warning(
- "Failed to abort files after a deterministic commit "
- "conflict.",
- exc_info=True,
- )
- raise
+ # Never abort files in response to a commit exception. Preserving
+ # possible orphan files is safer than deleting files which another
+ # attempt may still commit or which a snapshot may already reference.
+ if self.overwrite_partition is not None:
+ # Always call overwrite() even with empty messages, so that
+ # FileStoreCommit.overwrite can handle the empty case properly
+ # (e.g. static overwrite with empty data should delete the
partition).
+ logger.info(
+ "Committing overwrite to table %s, %d non-empty messages",
+ self.table.identifier, len(non_empty_messages)
+ )
+ self.file_store_commit.overwrite(
+ overwrite_partition=self.overwrite_partition,
+ commit_messages=non_empty_messages,
+ commit_identifier=commit_identifier
+ )
+ else:
+ if not non_empty_messages:
+ return
+ logger.info(
+ "Committing table %s, %d non-empty messages",
+ self.table.identifier, len(non_empty_messages)
+ )
+ self.file_store_commit.commit(
+ commit_messages=non_empty_messages,
+ commit_identifier=commit_identifier
+ )
def abort(self, commit_messages: List[CommitMessage]):
self.file_store_commit.abort(commit_messages)