This is an automated email from the ASF dual-hosted git repository.
JingsongLi 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 51fd07c210 [python] Fix update_by_row_id commit cleanup semantics
(#8522)
51fd07c210 is described below
commit 51fd07c2105671aa3da38b47cb4683e85b227391
Author: QuakeWang <[email protected]>
AuthorDate: Fri Jul 10 08:35:47 2026 +0800
[python] Fix update_by_row_id commit cleanup semantics (#8522)
---
paimon-python/pypaimon/ray/update_by_row_id.py | 60 +++++++-
.../pypaimon/tests/ray_update_by_row_id_test.py | 153 +++++++++++++++++++++
2 files changed, 208 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/ray/update_by_row_id.py
b/paimon-python/pypaimon/ray/update_by_row_id.py
index a538fb2f4b..37f2cfa447 100644
--- a/paimon-python/pypaimon/ray/update_by_row_id.py
+++ b/paimon-python/pypaimon/ray/update_by_row_id.py
@@ -23,6 +23,7 @@ shuffle join (unlike ``merge_into(on=["_ROW_ID"])``, which
reads and joins the w
target). Pairs with ``bucket_join``, which produces the row ids.
"""
+import logging
from typing import Any, Dict, List, Optional
import pyarrow as pa
@@ -38,6 +39,8 @@ from pypaimon.ray.data_evolution_merge_transform import
build_update_schema
__all__ = ["update_by_row_id"]
+logger = logging.getLogger(__name__)
+
def _blob_col_names(table: "FileStoreTable") -> set:
return {f.name for f in table.table_schema.fields
@@ -147,10 +150,57 @@ def update_by_row_id(
raise # _reraise_inner always raises; keeps msgs/num_updated defined
for linters
if msgs:
- wb = table.new_batch_write_builder()
- tc = wb.new_commit()
+ _commit_update_messages(table, msgs)
+ return {"num_updated": num_updated}
+
+
+def _commit_update_messages(table, commit_messages) -> None:
+ pending_msgs: list = list(commit_messages)
+ commit_started = False
+
+ try:
+ table_commit = None
try:
- tc.commit(msgs)
+ table_commit = table.new_batch_write_builder().new_commit()
+ commit_started = True
+ table_commit.commit(pending_msgs)
finally:
- tc.close()
- return {"num_updated": num_updated}
+ 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
+
+ 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,
+ )
+ 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",
+ close_error,
+ exc_info=close_error,
+ )
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 8b3c71d80a..613fa50d03 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
@@ -18,6 +18,7 @@
import os
import shutil
import tempfile
+import types
import unittest
import uuid
from unittest import mock
@@ -154,6 +155,48 @@ 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):
+ err = RuntimeError("new_commit failed")
+ recorder = {}
+
+ with self.assertRaisesRegex(RuntimeError, "new_commit failed"):
+ self._run_with_fake_commit(
+ recorder=recorder,
+ new_commit_errors=[err],
+ )
+
+ self.assertEqual(recorder["commit_calls"], 0)
+ self.assertEqual(recorder["abort_calls"], 1)
+ self.assertEqual(recorder["abort_msgs"], recorder["msgs"])
+
+ def test_commit_failure_does_not_abort_after_commit_started(self):
+ err = RuntimeError("commit failed")
+ recorder = {}
+
+ with self.assertRaisesRegex(RuntimeError, "commit failed"):
+ self._run_with_fake_commit(
+ recorder=recorder,
+ commit_error=err,
+ )
+
+ self.assertEqual(recorder["commit_calls"], 1)
+ self.assertEqual(recorder["abort_calls"], 0)
+ self.assertEqual(recorder["close_calls"], 1)
+
+ def test_close_failure_after_success_warns_and_returns_stats(self):
+ close_error = RuntimeError("close failed")
+
+ with self.assertLogs("pypaimon.ray.update_by_row_id", level="WARNING")
as logs:
+ recorder = self._run_with_fake_commit(close_error=close_error)
+
+ self.assertEqual(recorder["result"], {"num_updated": 3})
+ self.assertEqual(recorder["commit_calls"], 1)
+ self.assertEqual(recorder["abort_calls"], 0)
+ self.assertIn(
+ "Failed to close update_by_row_id commit",
+ "\n".join(logs.output),
+ )
+
def test_accepts_pyarrow_and_pandas_source(self):
target = self._create()
self._write(target, pa.Table.from_pydict(
@@ -281,6 +324,116 @@ class RayUpdateByRowIdTest(unittest.TestCase):
with self.assertRaises(ValueError):
update_by_row_id(target, src, self.catalog_options, update_cols=[])
+ def _run_with_fake_commit(self, *, recorder=None, new_commit_errors=None,
+ commit_error=None, close_error=None):
+ import importlib
+
+ m = importlib.import_module("pypaimon.ray.update_by_row_id")
+
+ if recorder is None:
+ recorder = {}
+ recorder.update({
+ "msgs": [object()],
+ "new_commit_errors": list(new_commit_errors or []),
+ "commit_error": commit_error,
+ "close_error": close_error,
+ "new_commit_calls": 0,
+ "commit_calls": 0,
+ "abort_calls": 0,
+ "close_calls": 0,
+ })
+
+ class FakeOptions:
+ def data_evolution_enabled(self):
+ return True
+
+ def row_tracking_enabled(self):
+ return True
+
+ def deletion_vectors_enabled(self):
+ return False
+
+ class FakeCommit:
+ def commit(self, msgs):
+ recorder["commit_calls"] += 1
+ recorder["commit_msgs"] = list(msgs)
+ if recorder["commit_error"] is not None:
+ raise recorder["commit_error"]
+
+ def abort(self, msgs):
+ recorder["abort_calls"] += 1
+ recorder["abort_msgs"] = list(msgs)
+
+ def close(self):
+ recorder["close_calls"] += 1
+ if recorder["close_error"] is not None:
+ raise recorder["close_error"]
+
+ class FakeWriteBuilder:
+ def new_commit(self):
+ recorder["new_commit_calls"] += 1
+ if recorder["new_commit_errors"]:
+ raise recorder["new_commit_errors"].pop(0)
+ return FakeCommit()
+
+ class FakeTable:
+ field_names = ["age"]
+ partition_keys = []
+ options = FakeOptions()
+ table_schema = types.SimpleNamespace(fields=[
+ types.SimpleNamespace(
+ name="age",
+ type=types.SimpleNamespace(type="INT"),
+ )
+ ])
+
+ def snapshot_manager(self):
+ return types.SimpleNamespace(get_latest_snapshot=lambda:
types.SimpleNamespace(
+ id=1,
+ total_record_count=1,
+ ))
+
+ def new_batch_write_builder(self):
+ return FakeWriteBuilder()
+
+ class FakeCatalog:
+ def get_table(self, target):
+ return FakeTable()
+
+ class FakeSource:
+ def schema(self):
+ return types.SimpleNamespace(names=["_ROW_ID", "age"])
+
+ def map_batches(self, fn, batch_format=None):
+ return self
+
+ parser_path = (
+ "pypaimon.schema.data_types.PyarrowFieldParser.from_paimon_schema"
+ )
+ with mock.patch(
+ "pypaimon.catalog.catalog_factory.CatalogFactory.create",
+ return_value=FakeCatalog()), \
+ mock.patch.object(m, "_normalize_source",
+ side_effect=lambda source, catalog_options:
source), \
+ mock.patch.object(m, "build_update_schema",
+ return_value=pa.schema([
+ ("_ROW_ID", pa.int64()),
+ ("age", pa.int32()),
+ ])), \
+ mock.patch(parser_path,
+ return_value=pa.schema([
+ ("age", pa.int32()),
+ ])), \
+ mock.patch.object(m, "distributed_update_apply",
+ return_value=(recorder["msgs"], 3, [])):
+ recorder["result"] = m.update_by_row_id(
+ "default.fake",
+ FakeSource(),
+ self.catalog_options,
+ update_cols=["age"],
+ )
+ return recorder
+
if __name__ == "__main__":
unittest.main()