This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 1ee2a104 [Core] Support caller snapshot properties in native commits 
(#1104)
1ee2a104 is described below

commit 1ee2a104aeafd2c907f99ad95e9a29529d2b7e18
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 19:49:21 2026 +0800

    [Core] Support caller snapshot properties in native commits (#1104)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  10 +-
 bindings/python/src/write.rs                       |  31 +++-
 bindings/python/tests/test_table_commit.py         |  51 ++++++
 crates/paimon/src/table/table_commit.rs            |  16 ++
 .../src/table/table_commit/property_tests.rs       | 182 +++++++++++++++++++++
 5 files changed, 279 insertions(+), 11 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 585f0f71..e0414953 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -425,7 +425,10 @@ class StreamTableWrite:
 
 class BatchTableCommit:
     def close(self) -> None: ...
-    def commit(self, messages: Sequence[CommitMessage]) -> None:
+    def commit(
+        self, messages: Sequence[CommitMessage],
+        snapshot_properties: Optional[Dict[str, str]] = None,
+    ) -> None:
         """Commit once, using the builder's overwrite configuration and batch 
identifier."""
         ...
     def truncate_table(self) -> None:
@@ -440,7 +443,10 @@ class BatchTableCommit:
 
 class StreamTableCommit:
     def close(self) -> None: ...
-    def commit(self, commit_identifier: int, messages: 
Sequence[CommitMessage]) -> None:
+    def commit(
+        self, commit_identifier: int, messages: Sequence[CommitMessage],
+        snapshot_properties: Optional[Dict[str, str]] = None,
+    ) -> None:
         """Commit a checkpoint, including empty checkpoints, without filtering 
retries."""
         ...
     def filter_and_commit(
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 7f1d0fd8..fffe64b9 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -1324,6 +1324,14 @@ impl CommitContext {
         py.detach(|| runtime().block_on(self.inner.abort(&messages)))
             .map_err(to_py_err)
     }
+
+    fn committer(&self, properties: Option<HashMap<String, String>>) -> 
TableCommit {
+        // Properties belong to this call, including retries. The reusable
+        // stream context must not carry them into a later checkpoint.
+        self.inner
+            .clone()
+            .with_snapshot_properties(properties.unwrap_or_default())
+    }
 }
 
 #[pyclass(name = "BatchTableCommit", module = "pypaimon_rust.datafusion")]
@@ -1351,18 +1359,24 @@ impl PyBatchTableCommit {
     /// Rust committers have no background resources to shut down.
     fn close(&self) {}
 
-    fn commit(&mut self, py: Python<'_>, messages: &Bound<'_, PyAny>) -> 
PyResult<()> {
+    #[pyo3(signature = (messages, snapshot_properties=None))]
+    fn commit(
+        &mut self,
+        py: Python<'_>,
+        messages: &Bound<'_, PyAny>,
+        snapshot_properties: Option<HashMap<String, String>>,
+    ) -> PyResult<()> {
         let messages = self.context.messages(messages, "commit", 
self.overwrite)?;
         self.check_committed()?;
+        let committer = self.context.committer(snapshot_properties);
         py.detach(|| {
             runtime().block_on(async {
                 if self.overwrite {
-                    self.context
-                        .inner
+                    committer
                         .overwrite(messages, self.static_partition.clone())
                         .await
                 } else {
-                    self.context.inner.commit(messages).await
+                    committer.commit(messages).await
                 }
             })
         })
@@ -1403,19 +1417,18 @@ impl PyStreamTableCommit {
     /// Rust committers have no background resources to shut down.
     fn close(&self) {}
 
+    #[pyo3(signature = (commit_identifier, messages, 
snapshot_properties=None))]
     fn commit(
         &self,
         py: Python<'_>,
         commit_identifier: i64,
         messages: &Bound<'_, PyAny>,
+        snapshot_properties: Option<HashMap<String, String>>,
     ) -> PyResult<()> {
         let messages = self.context.messages(messages, "commit", false)?;
+        let committer = self.context.committer(snapshot_properties);
         py.detach(|| {
-            runtime().block_on(
-                self.context
-                    .inner
-                    .commit_with_identifier(messages, commit_identifier),
-            )
+            runtime().block_on(committer.commit_with_identifier(messages, 
commit_identifier))
         })
         .map_err(to_py_err)
     }
diff --git a/bindings/python/tests/test_table_commit.py 
b/bindings/python/tests/test_table_commit.py
index 00725b0e..ffb69285 100644
--- a/bindings/python/tests/test_table_commit.py
+++ b/bindings/python/tests/test_table_commit.py
@@ -155,6 +155,57 @@ def 
test_deserialized_batch_commit_uses_target_builder(tmp_path):
     assert _rows(table) == [1]
 
 
[email protected]("overwrite", [False, True])
[email protected]("properties", [None, {}, {"source": "capture", 
"label": "中文"}])
+def test_batch_snapshot_properties(tmp_path, overwrite, properties):
+    table = _table(tmp_path)
+    _append(table, [1], [10])
+    builder = table.new_batch_write_builder()
+    if overwrite:
+        builder.with_overwrite()
+    messages = _roundtrip(_prepare(builder, [2], [20]))
+    committer = builder.new_commit()
+    committer.commit(messages, snapshot_properties=properties)
+    snapshot = _snapshot(table)
+    assert snapshot.get("properties") == (properties or None)
+    assert snapshot["commitKind"] == ("OVERWRITE" if overwrite else "APPEND")
+    assert _rows(table) == ([2] if overwrite else [1, 2])
+    with pytest.raises(RuntimeError, match="one-time"):
+        committer.commit(messages, snapshot_properties={"source": "retry"})
+    assert _snapshot(table)["id"] == snapshot["id"]
+
+
+def test_stream_snapshot_properties_are_per_checkpoint(tmp_path):
+    table = _table(tmp_path)
+    builder = 
table.new_stream_write_builder().with_commit_user("properties-job")
+    committer = builder.new_commit()
+    properties = {"checkpoint": "first"}
+    for identifier, metadata in enumerate([properties, None, {}, 
{"checkpoint": "fourth"}], 1):
+        messages = _prepare(builder, [identifier], [identifier * 10], 
identifier)
+        committer.commit(identifier, messages, snapshot_properties=metadata)
+        snapshot = _snapshot(table)
+        assert snapshot["commitIdentifier"] == identifier
+        assert snapshot.get("properties") == (metadata or None)
+        if not metadata:
+            assert "properties" not in snapshot
+    properties["checkpoint"] = "changed"
+    first = json.loads((Path(table.location()) / "snapshot" / 
"snapshot-1").read_text())
+    assert first["properties"] == {"checkpoint": "first"}
+    assert _rows(table) == [1, 2, 3, 4]
+
+
+def test_empty_stream_snapshot_properties(tmp_path):
+    table = _table(tmp_path)
+    committer = 
table.new_stream_write_builder().with_commit_user("empty-job").new_commit()
+    committer.commit(42, [], snapshot_properties={"source": "empty"})
+    snapshot = _snapshot(table)
+    assert snapshot["properties"] == {"source": "empty"}
+    assert snapshot["totalRecordCount"] == 0
+    assert snapshot["deltaRecordCount"] == 0
+    committer.commit(43, [])
+    assert "properties" not in _snapshot(table)
+
+
 @pytest.mark.parametrize("overwrite", [False, True])
 @pytest.mark.parametrize("serialized", [False, True])
 def test_batch_bridge_preserves_external_commit_user(tmp_path, overwrite, 
serialized):
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index 698bea47..d4d2dba8 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -188,6 +188,7 @@ fn reject_compact_increment(messages: &[CommitMessage]) -> 
Result<()> {
 /// Table commit logic for Paimon write operations.
 ///
 /// Provides atomic commit functionality including append, overwrite and 
truncate
+#[derive(Clone)]
 pub struct TableCommit {
     table: Table,
     snapshot_manager: SnapshotManager,
@@ -206,6 +207,7 @@ pub struct TableCommit {
     row_tracking_enabled: bool,
     data_evolution_enabled: bool,
     partition_default_name: String,
+    snapshot_properties: Option<HashMap<String, String>>,
 }
 
 impl TableCommit {
@@ -247,6 +249,7 @@ impl TableCommit {
             row_tracking_enabled,
             data_evolution_enabled,
             partition_default_name,
+            snapshot_properties: None,
         }
     }
 
@@ -256,6 +259,17 @@ impl TableCommit {
         self
     }
 
+    /// Attach caller properties to snapshots published by this committer.
+    ///
+    /// Like Java's ManifestCommittable properties, these describe the current
+    /// commit and are not inherited from the previous snapshot. Empty maps are
+    /// omitted. Clone the committer to configure metadata for a single call
+    /// while retaining its publication environment and retry configuration.
+    pub fn with_snapshot_properties(mut self, properties: HashMap<String, 
String>) -> Self {
+        self.snapshot_properties = 
(!properties.is_empty()).then_some(properties);
+        self
+    }
+
     /// Java StreamTableCommit.filterAndCommit: sort identifiers and return the
     /// number of groups remaining after filtering against committed snapshots.
     pub async fn filter_and_commit(
@@ -1364,6 +1378,7 @@ impl TableCommit {
             .statistics(statistics_file)
             .next_row_id(next_row_id)
             .index_manifest(resolved.index_manifest_name.clone())
+            .properties(self.snapshot_properties.clone())
             .build();
 
         let statistics = 
self.generate_partition_statistics(&resolved.entries)?;
@@ -3600,6 +3615,7 @@ mod tests {
         include!("table_commit/recovery_tests.rs");
         include!("table_commit/row_tracking_tests.rs");
         include!("table_commit/abort_tests.rs");
+        include!("table_commit/property_tests.rs");
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/table_commit/property_tests.rs 
b/crates/paimon/src/table/table_commit/property_tests.rs
new file mode 100644
index 00000000..4d97e6af
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/property_tests.rs
@@ -0,0 +1,182 @@
+// 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.
+
+#[tokio::test]
+async fn snapshot_properties_are_not_inherited_between_commits() {
+    let io = test_file_io();
+    let path = "memory:/snapshot-call-properties";
+    setup_dirs(&io, path).await;
+    let commit = setup_commit(&io, path).with_ignore_empty_commit(false);
+    let properties = HashMap::from([
+        ("source".into(), "capture".into()),
+        ("label".into(), "中文".into()),
+    ]);
+    let configured = 
commit.clone().with_snapshot_properties(properties.clone());
+    configured
+        .commit_with_identifier(vec![append_message("data.parquet")], 1)
+        .await
+        .unwrap();
+    let first = latest_snapshot(&io, path).await.unwrap();
+    assert_eq!(first.properties(), Some(&properties));
+
+    // Empty snapshots also carry current-call metadata, not inherited values.
+    commit.commit_with_identifier(Vec::new(), 2).await.unwrap();
+    let second = latest_snapshot(&io, path).await.unwrap();
+    assert_eq!(second.properties(), None);
+    assert!(serde_json::to_value(&second)
+        .unwrap()
+        .get("properties")
+        .is_none());
+
+    configured
+        .clone()
+        .with_snapshot_properties(HashMap::new())
+        .commit_with_identifier(Vec::new(), 3)
+        .await
+        .unwrap();
+    assert_eq!(latest_snapshot(&io, path).await.unwrap().properties(), None);
+
+    configured
+        .commit_with_identifier(Vec::new(), 4)
+        .await
+        .unwrap();
+    let fourth = latest_snapshot(&io, path).await.unwrap();
+    assert_eq!(fourth.properties(), Some(&properties));
+    assert_eq!(fourth.delta_record_count(), Some(0));
+    assert_eq!(fourth.total_record_count(), first.total_record_count());
+}
+
+#[tokio::test]
+async fn snapshot_properties_overwrite_recovery_preserves_original_metadata() {
+    let io = test_file_io();
+    let path = "memory:/snapshot-overwrite-properties";
+    setup_dirs(&io, path).await;
+    let commit = setup_commit(&io, path);
+    commit
+        .commit_with_identifier(vec![append_message("old.parquet")], 1)
+        .await
+        .unwrap();
+    let properties = HashMap::from([("source".into(), "overwrite".into())]);
+    let configured = 
commit.clone().with_snapshot_properties(properties.clone());
+    configured
+        .overwrite_with_identifier(Vec::new(), None, 2)
+        .await
+        .unwrap();
+    let snapshot = latest_snapshot(&io, path).await.unwrap();
+    assert_eq!(snapshot.properties(), Some(&properties));
+    assert_eq!(snapshot.commit_kind(), &CommitKind::OVERWRITE);
+    assert_eq!(snapshot.total_record_count(), Some(0));
+
+    let other = TableCommit::new(test_table(&io, path), "other-user".into());
+    other
+        .commit_with_identifier(vec![append_message("intervening.parquet")], 1)
+        .await
+        .unwrap();
+    // Recovered identities retain their original metadata and do not apply
+    // the caller's new properties or remove an intervening writer's data.
+    configured
+        .with_snapshot_properties(HashMap::from([("source".into(), 
"retry".into())]))
+        .overwrite_with_identifier(Vec::new(), None, 2)
+        .await
+        .unwrap();
+    let latest = latest_snapshot(&io, path).await.unwrap();
+    assert_eq!(latest.id(), 3);
+    assert_eq!(latest.properties(), None);
+    assert_eq!(active_entries(&io, path, &latest).await.len(), 1);
+    assert_eq!(
+        commit
+            .snapshot_manager
+            .get_snapshot(2)
+            .await
+            .unwrap()
+            .properties(),
+        Some(&properties)
+    );
+}
+
+struct PropertyPublicationFault {
+    manager: SnapshotManager,
+    response_lost: bool,
+    calls: std::sync::atomic::AtomicUsize,
+    attempts: std::sync::Mutex<Vec<Snapshot>>,
+}
+
+#[async_trait::async_trait]
+impl SnapshotCommit for PropertyPublicationFault {
+    async fn commit(
+        &self,
+        _base_snapshot_uuid: Option<&str>,
+        snapshot: &Snapshot,
+        _branch: &str,
+        _statistics: &[PartitionStatistics],
+    ) -> Result<bool> {
+        self.attempts.lock().unwrap().push(snapshot.clone());
+        let first = self.calls.fetch_add(1, 
std::sync::atomic::Ordering::SeqCst) == 0;
+        if first && !self.response_lost {
+            return Ok(false);
+        }
+        let published = self.manager.commit_snapshot(snapshot).await?;
+        if first && self.response_lost {
+            return Err(crate::Error::UnexpectedError {
+                message: "response lost after publication".into(),
+                source: None,
+            });
+        }
+        Ok(published)
+    }
+}
+
+#[tokio::test]
+async fn snapshot_properties_survive_conflict_and_uncertain_publication() {
+    for response_lost in [false, true] {
+        let io = test_file_io();
+        let path = 
format!("memory:/snapshot-properties-fault-{response_lost}");
+        setup_dirs(&io, &path).await;
+        let mut commit = setup_commit(&io, &path);
+        commit.commit_min_retry_wait_ms = 0;
+        commit.commit_max_retry_wait_ms = 0;
+        let fault = Arc::new(PropertyPublicationFault {
+            manager: commit.snapshot_manager.clone(),
+            response_lost,
+            calls: std::sync::atomic::AtomicUsize::new(0),
+            attempts: std::sync::Mutex::new(Vec::new()),
+        });
+        commit.snapshot_commit = fault.clone();
+        let data_path = format!("{path}/bucket-0/prepared.parquet");
+        io.new_output(&data_path)
+            .unwrap()
+            .write(bytes::Bytes::from_static(b"prepared file retained"))
+            .await
+            .unwrap();
+        let properties = HashMap::from([("source".into(), "retried".into())]);
+        commit
+            .with_snapshot_properties(properties.clone())
+            .commit_with_identifier(vec![append_message("prepared.parquet")], 
7)
+            .await
+            .unwrap();
+        let snapshot = latest_snapshot(&io, &path).await.unwrap();
+        assert_eq!(snapshot.id(), 1);
+        assert_eq!(snapshot.properties(), Some(&properties));
+        assert_eq!(active_entries(&io, &path, &snapshot).await.len(), 1);
+        assert!(io.exists(&data_path).await.unwrap());
+        let attempts = fault.attempts.lock().unwrap();
+        assert_eq!(attempts.len(), if response_lost { 1 } else { 2 });
+        assert!(attempts
+            .iter()
+            .all(|attempt| attempt.properties() == Some(&properties)));
+    }
+}

Reply via email to