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 cd95f04b1a [Python] Commit snapshot properties through Native (#10505)
cd95f04b1a is described below
commit cd95f04b1a3b8e198caf120acf6e9f6a5f28bde2
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 19:49:45 2026 +0800
[Python] Commit snapshot properties through Native (#10505)
---
paimon-python/pypaimon/tests/native_commit_test.py | 7 +-
.../tests/native_snapshot_properties_test.py | 160 +++++++++++++++++++++
paimon-python/pypaimon/write/table_commit.py | 30 ++--
3 files changed, 179 insertions(+), 18 deletions(-)
diff --git a/paimon-python/pypaimon/tests/native_commit_test.py
b/paimon-python/pypaimon/tests/native_commit_test.py
index 3b62aa0ac5..31d9b63a19 100644
--- a/paimon-python/pypaimon/tests/native_commit_test.py
+++ b/paimon-python/pypaimon/tests/native_commit_test.py
@@ -441,16 +441,17 @@ def
test_publication_response_loss_does_not_duplicate_or_delete_files(
@pytest.mark.parametrize('properties', [{}, {'source': 'python'}])
@pytest.mark.parametrize('overwrite', [False, True])
-def test_snapshot_properties_select_python_before_native(tmp_path, properties,
overwrite):
+def test_non_rest_snapshot_properties_keep_python_commit(tmp_path, properties,
overwrite):
table = _table(tmp_path)
builder = table.new_batch_write_builder()
if overwrite:
builder.overwrite()
commit = builder.new_commit()
with patch('pypaimon.write.native_commit.create_native_commit',
- side_effect=AssertionError('must not initialize native')) as
create:
+ wraps=create_native_commit) as create:
commit.commit(_prepare(builder, [{'id': 1, 'pt': None}]),
snapshot_properties=properties)
- create.assert_not_called()
+ create.assert_called_once()
+ assert commit._native_commit is None
assert table.snapshot_manager().get_latest_snapshot().properties ==
(properties or None)
commit.close()
diff --git a/paimon-python/pypaimon/tests/native_snapshot_properties_test.py
b/paimon-python/pypaimon/tests/native_snapshot_properties_test.py
new file mode 100644
index 0000000000..9080e1947d
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_snapshot_properties_test.py
@@ -0,0 +1,160 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import Mock, patch
+
+import pytest
+
+from pypaimon.tests import native_commit_test
+
+pytestmark = pytest.mark.native_plan
+
+
+@native_commit_test.requires_native
[email protected]('mode', ['append', 'pk', 'de'])
[email protected]('properties', [None, {}, {'source': 'capture',
'label': '中文'}])
[email protected]('overwrite', [False, True])
+def test_rest_native_snapshot_properties(tmp_path, native_rest_catalog, mode,
properties, overwrite):
+ table = native_commit_test._table(tmp_path, mode,
catalog=native_rest_catalog)
+ native_commit_test._seed(table)
+ builder = table.new_batch_write_builder()
+ if overwrite:
+ builder.overwrite()
+ messages = native_commit_test._prepare(builder, [{'id': 10, 'pt': 'a'}])
+ commit = builder.new_commit()
+ try:
+ with native_commit_test._must_not_fallback(commit, 'overwrite' if
overwrite else 'commit'):
+ commit.commit(messages, snapshot_properties=properties)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.properties == (properties or None)
+ expected = ([{'id': 10, 'pt': 'a'}] if mode == 'pk' else [
+ {'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None}, {'id': 10, 'pt':
'a'}]) if overwrite else [
+ {'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}, {'id': 3, 'pt': None},
{'id': 10, 'pt': 'a'}]
+ assert native_commit_test._rows(table) == expected
+ finally:
+ commit.close()
+
+
+@native_commit_test.requires_native
[email protected]('mode', ['append', 'pk', 'de'])
+def test_stream_properties_do_not_leak_between_checkpoints(tmp_path,
native_rest_catalog, mode):
+ table = native_commit_test._table(tmp_path, mode,
catalog=native_rest_catalog)
+ builder = table.new_stream_write_builder()
+ commit = builder.new_commit()
+ properties = {'checkpoint': 'first'}
+ try:
+ with native_commit_test._must_not_fallback(commit):
+ for identifier, metadata in enumerate([properties, None, {},
{'checkpoint': 'fourth'}], 1):
+ messages = native_commit_test._prepare(builder, [{'id':
identifier, 'pt': 'a'}], identifier)
+ commit.commit(messages, identifier,
snapshot_properties=metadata)
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.commit_identifier == identifier
+ assert snapshot.properties == (metadata or None)
+ properties['checkpoint'] = 'changed'
+ assert table.snapshot_manager().get_snapshot_by_id(1).properties ==
{'checkpoint': 'first'}
+ assert native_commit_test._rows(table) == [{'id': i, 'pt': 'a'} for i
in range(1, 5)]
+ finally:
+ commit.close()
+
+
+@native_commit_test.requires_native
[email protected]('ignore_empty', [False, True])
+def test_empty_append_properties_follow_empty_commit_option(tmp_path,
native_rest_catalog, ignore_empty):
+ table = native_commit_test._table(tmp_path,
catalog=native_rest_catalog).copy(
+ {'snapshot.ignore-empty-commit': str(ignore_empty).lower()})
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ with native_commit_test._must_not_fallback(commit):
+ commit.commit([], snapshot_properties={'source': 'empty'})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ if ignore_empty:
+ assert snapshot is None
+ else:
+ assert snapshot.properties == {'source': 'empty'}
+ assert snapshot.total_record_count == 0
+ assert snapshot.delta_record_count == 0
+ finally:
+ commit.close()
+
+
+@native_commit_test.requires_native
[email protected]('mode', ['append', 'de'])
+def test_empty_static_overwrite_records_properties(tmp_path,
native_rest_catalog, mode):
+ table = native_commit_test._table(tmp_path, mode,
catalog=native_rest_catalog).copy(
+ {'dynamic-partition-overwrite': 'false'})
+ native_commit_test._seed(table)
+ builder = table.new_batch_write_builder().overwrite({'pt': 'a'})
+ commit = builder.new_commit()
+ try:
+ with native_commit_test._must_not_fallback(commit, 'overwrite'):
+ commit.commit([], snapshot_properties={'reason': 'empty
overwrite'})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.properties == {'reason': 'empty overwrite'}
+ assert snapshot.commit_kind == 'OVERWRITE'
+ assert snapshot.delta_record_count == -1
+ assert native_commit_test._rows(table) == [{'id': 2, 'pt': 'b'},
{'id': 3, 'pt': None}]
+ finally:
+ commit.close()
+
+
+@native_commit_test.requires_native
+def test_empty_dynamic_overwrite_does_not_publish_properties(tmp_path,
native_rest_catalog):
+ table = native_commit_test._table(tmp_path,
catalog=native_rest_catalog).copy(
+ {'dynamic-partition-overwrite': 'true'})
+ native_commit_test._seed(table)
+ commit = table.new_batch_write_builder().overwrite().new_commit()
+ try:
+ with native_commit_test._must_not_fallback(commit, 'overwrite'):
+ commit.commit([], snapshot_properties={'source': 'unused'})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.id == 1
+ assert snapshot.properties is None
+ assert len(native_commit_test._rows(table)) == 3
+ finally:
+ commit.close()
+
+
+@native_commit_test.requires_native
[email protected]('overwrite', [False, True])
+def test_properties_survive_publication_response_loss(tmp_path,
native_rest_catalog, overwrite):
+ table = native_commit_test._table(tmp_path, catalog=native_rest_catalog)
+ builder = table.new_batch_write_builder()
+ if overwrite:
+ builder.overwrite({'pt': 'a'})
+ messages = native_commit_test._prepare(builder, [{'id': 1, 'pt': 'a'}])
+ native = native_commit_test.create_native_commit(table,
builder.commit_user, builder.static_partition)
+ proxy = Mock(wraps=native)
+
+ def publish_then_fail(*args, **kwargs):
+ native.commit(*args, **kwargs)
+ raise OSError('response lost')
+
+ proxy.commit.side_effect = publish_then_fail
+ commit = builder.new_commit()
+ try:
+ with patch('pypaimon.write.native_commit.create_native_commit',
return_value=proxy), \
+ native_commit_test._must_not_fallback(commit, 'overwrite' if
overwrite else 'commit'), \
+ patch.object(commit.file_store_commit, 'abort',
side_effect=AssertionError('abort')):
+ with pytest.raises(OSError, match='response lost'):
+ commit.commit(messages, snapshot_properties={'source':
'published'})
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.id == 1
+ assert snapshot.properties == {'source': 'published'}
+ assert native_commit_test._rows(table) == [{'id': 1, 'pt': 'a'}]
+ proxy.abort.assert_not_called()
+ finally:
+ commit.close()
diff --git a/paimon-python/pypaimon/write/table_commit.py
b/paimon-python/pypaimon/write/table_commit.py
index 894d616fba..fb0ac3db6d 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -74,6 +74,8 @@ class TableCommit:
}
if snapshot_properties is not None:
commit_kwargs["snapshot_properties"] = snapshot_properties
+ native_kwargs = ({} if snapshot_properties is None else
+ {"snapshot_properties": snapshot_properties})
# Never abort files in response to a commit exception. Preserving
# possible orphan files is safer than deleting files which another
@@ -86,13 +88,12 @@ class TableCommit:
"Committing overwrite to table %s, %d non-empty messages",
self.table.identifier, len(non_empty_messages)
)
- if snapshot_properties is None:
- prepared = self._prepare_native_commit(non_empty_messages)
- if prepared is not None:
- native, messages = prepared
- # Keep publication failures outside the preparation
fallback.
- native.commit(messages)
- return
+ prepared = self._prepare_native_commit(non_empty_messages)
+ if prepared is not None:
+ native, messages = prepared
+ # Keep publication failures outside the preparation fallback.
+ native.commit(messages, **native_kwargs)
+ return
self.file_store_commit.overwrite(
overwrite_partition=self.overwrite_partition,
**commit_kwargs)
@@ -104,14 +105,13 @@ class TableCommit:
"Committing table %s, %d non-empty messages",
self.table.identifier, len(non_empty_messages)
)
- if snapshot_properties is None:
- prepared = self._prepare_native_commit(non_empty_messages)
- if prepared is not None:
- native, messages = prepared
- # Mutation is deliberately outside the fallback boundary:
- # an exception can mean the snapshot was already published.
- native.commit(commit_identifier, messages)
- return
+ prepared = self._prepare_native_commit(non_empty_messages)
+ if prepared is not None:
+ native, messages = prepared
+ # Mutation is deliberately outside the fallback boundary:
+ # an exception can mean the snapshot was already published.
+ native.commit(commit_identifier, messages, **native_kwargs)
+ return
self.file_store_commit.commit(**commit_kwargs)
def _prepare_native_commit(self, messages):