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 3ce22437 feat: Add Java-compatible CommitMessage serialization and
Python commit API (#912)
3ce22437 is described below
commit 3ce2243776b69ace304bb690ec8fdd6f75227c2d
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 16:48:04 2026 +0800
feat: Add Java-compatible CommitMessage serialization and Python commit API
(#912)
---
bindings/python/README.md | 61 ++-
.../python/python/pypaimon_rust/datafusion.pyi | 79 ++-
bindings/python/src/context.rs | 9 +-
bindings/python/src/table.rs | 11 +-
bindings/python/src/write.rs | 604 ++++++++++++++++-----
bindings/python/tests/test_table_commit.py | 415 ++++++++++++++
bindings/python/tests/test_write.py | 46 +-
crates/paimon/src/spec/index_file_meta.rs | 217 ++++++++
crates/paimon/src/table/commit_message.rs | 258 ++++++++-
crates/paimon/src/table/data_file_writer.rs | 4 +-
.../src/table/dedicated_format_file_writer.rs | 10 +
crates/paimon/src/table/kv_file_writer.rs | 19 +
crates/paimon/src/table/mod.rs | 2 +-
crates/paimon/src/table/postpone_file_writer.rs | 16 +
crates/paimon/src/table/table_commit.rs | 241 +++++++-
crates/paimon/src/table/table_write.rs | 13 +
docs/src/python-binding.md | 6 +-
17 files changed, 1817 insertions(+), 194 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 53f3a8ee..c55c4010 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -90,7 +90,7 @@ batch = pa.record_batch(
[[3, 4], ["charlie", "diana"]],
schema=pa.schema([("id", pa.int32()), ("name", pa.utf8())]),
)
-write_builder = table.new_write_builder()
+write_builder = table.new_batch_write_builder()
writer = write_builder.new_write()
writer.write_arrow(batch)
commit_messages = writer.prepare_commit()
@@ -107,6 +107,65 @@ print(f"\nRead: {batches_tt[0].num_rows} rows")
print(batches_tt[0])
```
+### Native commit from serialized messages
+
+The Python binding follows Java's batch/stream builder structure. Use
+`table.new_batch_write_builder()` for batch writes and
+`table.new_stream_write_builder().with_commit_user("ingest-job")` for
streaming.
+Both create writers and committers with the same commit identity.
+
+```python
+from pypaimon_rust.datafusion import CommitMessage
+
+builder = table.new_stream_write_builder().with_commit_user("ingest-job")
+committer = builder.new_commit()
+messages = [
+ CommitMessage.deserialize(body, version=14)
+ for body in serialized_messages
+]
+committer.commit(42, messages)
+
+# After an uncertain result, restore the same user and retry checkpoint groups.
+restored =
table.new_stream_write_builder().with_commit_user("ingest-job").new_commit()
+committed_groups = restored.filter_and_commit({42: messages})
+```
+
+Stream identifiers increase monotonically per commit user. `filter_and_commit`
+sorts them and returns the number of groups committed after filtering. Empty
+stream checkpoints create snapshots recording their identifiers. Batch
+`commit(messages)` uses Java's batch identifier and permits one attempt per
+committer. Batch empty commits follow `snapshot.ignore-empty-commit` (default
+true). `truncate_table()` shares the batch commit guard; `truncate_partitions`
+accepts a nonempty list of partition specs as in Java.
+
+Configure overwrite on the batch builder:
+
+```python
+builder = table.new_batch_write_builder().with_overwrite()
+writer = builder.new_write()
+writer.write_arrow(batch)
+builder.new_commit().commit(writer.prepare_commit())
+```
+
+This configures both writer and committer. For partitioned tables,
+`dynamic-partition-overwrite=true` (the default) replaces touched partitions,
+including when a static spec was supplied; empty input deletes nothing.
+With that option false, `with_overwrite(spec)` replaces matching partitions and
+`with_overwrite()` replaces all. Unpartitioned empty overwrite truncates the
+whole table. Explicit `with_overwrite(None)` restores append. Partition values
+use schema-compatible Python values; `None` or the default partition name means
+null. Batch writers permit one `prepare_commit()` call; reusable stream writers
+use `prepare_commit(wait_compaction, commit_identifier)`.
+
+The Java v14 body has no version header, table identity, commit user, or
overwrite
+mode. `CommitMessage.deserialize(body, version=14)` decodes it without a table
+or builder. Submit decoded messages to their originating table; commit identity
+and overwrite mode come from the configured committer. Messages returned
directly
+by local writers retain their table and commit-user checks.
+Only v14 is supported. `abort(messages)` deletes newly written files and must
+only be used for messages known not to have committed. Compact increments
remain
+unsupported by the Rust committer and are rejected.
+
### Tables resolved outside the Rust catalog
`Table.from_resolved_schema(location, schema_json, *, database="default",
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 4d0811ab..b3ebed81 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -163,7 +163,8 @@ class Table:
def location(self) -> str: ...
def schema(self) -> TableSchema: ...
def new_read_builder(self, options: Optional[Dict[str, str]] = None) ->
ReadBuilder: ...
- def new_write_builder(self) -> "WriteBuilder": ...
+ def new_batch_write_builder(self) -> "BatchWriteBuilder": ...
+ def new_stream_write_builder(self) -> "StreamWriteBuilder": ...
def latest_snapshot(self) -> Optional[Snapshot]:
"""
Warning: This method blocks on a DataFusion runtime.
@@ -197,22 +198,78 @@ class Table:
"""
...
-class CommitMessage: ...
+class CommitMessage:
+ @staticmethod
+ def deserialize(data: bytes, *, version: int = 14) -> "CommitMessage":
+ """Decode a Java body; table, commit user and overwrite mode come from
the committer."""
+ ...
+ def serialize(self) -> bytes:
+ """Java CommitMessageSerializer v14 body, without a version header."""
+ ...
+
+class BatchTableWrite:
+ def close(self) -> None: ...
+ def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
+ def prepare_commit(self) -> List[CommitMessage]:
+ """Prepare once per instance, including empty or failed attempts."""
+ ...
-class TableWrite:
+class StreamTableWrite:
+ def close(self) -> None: ...
def write_arrow(self, batch: pyarrow.RecordBatch) -> None: ...
- def prepare_commit(self) -> List[CommitMessage]: ...
+ def prepare_commit(self, wait_compaction: bool, commit_identifier: int) ->
List[CommitMessage]:
+ """Flush a checkpoint; submit its messages with the same identifier.
-class TableCommit:
- def commit(self, messages: Sequence[CommitMessage]) -> None: ...
+ Rust currently flushes synchronously without background compaction.
+ """
+ ...
+
+class BatchTableCommit:
+ def close(self) -> None: ...
+ def commit(self, messages: Sequence[CommitMessage]) -> None:
+ """Commit once, using the builder's overwrite configuration and batch
identifier."""
+ ...
+ def truncate_table(self) -> None:
+ """Truncate all data; shares the one-time guard with commit()."""
+ ...
+ def truncate_partitions(self, partitions: Sequence[Dict[str, Any]]) ->
None:
+ """Truncate matching partitions. Reject empty input, as Java does."""
+ ...
def abort(self, messages: Sequence[CommitMessage]) -> None:
- """Delete the files the messages refer to. Best-effort: missing files
and
- storage errors are ignored. The messages must not be committed
afterwards."""
+ """Best-effort deletion of newly written files; only abort uncommitted
messages."""
+ ...
+
+class StreamTableCommit:
+ def close(self) -> None: ...
+ def commit(self, commit_identifier: int, messages:
Sequence[CommitMessage]) -> None:
+ """Commit a checkpoint, including empty checkpoints, without filtering
retries."""
+ ...
+ def filter_and_commit(
+ self, commit_identifiers_and_messages: Dict[int,
Sequence[CommitMessage]]
+ ) -> int:
+ """Sort identifiers, skip committed groups, and return the number
committed."""
+ ...
+ def abort(self, messages: Sequence[CommitMessage]) -> None: ...
+
+class BatchWriteBuilder:
+ def with_overwrite(self, static_partition: Optional[Dict[str, Any]] = {})
-> "BatchWriteBuilder":
+ """Configure both writer and committer. Explicit None restores append.
+
+ For partitioned tables dynamic-partition-overwrite defaults to true:
+ replace touched partitions, irrespective of the static spec; empty
input
+ removes nothing. With the option false, match the spec ({} means all).
+ An unpartitioned empty overwrite truncates the table. Partition values
+ use the schema's Python types; None or the default partition name is
null.
+ """
...
+ def new_write(self) -> BatchTableWrite: ...
+ def new_commit(self) -> BatchTableCommit: ...
-class WriteBuilder:
- def new_write(self) -> TableWrite: ...
- def new_commit(self) -> TableCommit: ...
+class StreamWriteBuilder:
+ def commit_user(self) -> str: ...
+ def with_commit_user(self, commit_user: str) -> "StreamWriteBuilder": ...
+ def new_write(self) -> StreamTableWrite: ...
+ def new_commit(self) -> StreamTableCommit: ...
class PaimonCatalog:
def __init__(self, catalog_options: Dict[str, str]) -> None: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 1e285fd1..de431c60 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -548,9 +548,12 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::schema::PyDataField>()?;
this.add_class::<PyPythonScalarUDFObject>()?;
this.add_class::<PySQLContext>()?;
- this.add_class::<crate::write::PyWriteBuilder>()?;
- this.add_class::<crate::write::PyTableWrite>()?;
- this.add_class::<crate::write::PyTableCommit>()?;
+ this.add_class::<crate::write::PyBatchWriteBuilder>()?;
+ this.add_class::<crate::write::PyStreamWriteBuilder>()?;
+ this.add_class::<crate::write::PyBatchTableWrite>()?;
+ this.add_class::<crate::write::PyStreamTableWrite>()?;
+ this.add_class::<crate::write::PyBatchTableCommit>()?;
+ this.add_class::<crate::write::PyStreamTableCommit>()?;
this.add_class::<crate::write::PyCommitMessage>()?;
this.add_function(wrap_pyfunction!(udf, &this)?)?;
this.add_class::<crate::snapshot::PySnapshot>()?;
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 50ca8072..06c6316c 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -32,7 +32,7 @@ use crate::read::PyReadBuilder;
use crate::schema::PyTableSchema;
use crate::snapshot::PySnapshot;
use crate::tag::PyTag;
-use crate::write::PyWriteBuilder;
+use crate::write::{PyBatchWriteBuilder, PyStreamWriteBuilder};
#[pyclass(name = "Table", module = "pypaimon_rust.datafusion")]
pub struct PyTable {
@@ -133,9 +133,12 @@ impl PyTable {
}
}
- /// Create a [`PyWriteBuilder`] for the batch write loop.
- fn new_write_builder(&self) -> PyWriteBuilder {
- PyWriteBuilder::new(Arc::clone(&self.inner))
+ fn new_batch_write_builder(&self) -> PyBatchWriteBuilder {
+ PyBatchWriteBuilder::new(Arc::clone(&self.inner))
+ }
+
+ fn new_stream_write_builder(&self) -> PyStreamWriteBuilder {
+ PyStreamWriteBuilder::new(Arc::clone(&self.inner))
}
// ---------------- #285: observability ----------------
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 07f0d7d3..4caa28a5 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -15,17 +15,23 @@
// specific language governing permissions and limitations
// under the License.
+use std::collections::HashMap;
use std::sync::Arc;
use arrow::datatypes::Schema as ArrowSchema;
use arrow::pyarrow::FromPyArrow;
use arrow::record_batch::RecordBatch;
-use paimon::table::{CommitMessage, Table, TableCommit, TableWrite};
+use paimon::spec::{CoreOptions, DataType, Datum};
+use paimon::table::{
+ CommitMessage, Table, TableCommit, TableWrite,
COMMIT_MESSAGE_SERIALIZER_VERSION,
+};
use paimon_datafusion::runtime::runtime;
-use pyo3::exceptions::{PyTypeError, PyValueError};
+use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyDict, PyString};
use crate::error::to_py_err;
+use crate::predicate::py_to_datum;
/// Validate an incoming batch schema against the table's target Arrow schema:
/// field count, order, and names must match, and types must match exactly. The
@@ -60,128 +66,491 @@ fn validate_batch_schema(input: &ArrowSchema, target:
&ArrowSchema) -> PyResult<
Ok(())
}
-/// Builder for the batch write loop, created via
[`crate::table::PyTable::new_write_builder`].
-///
-/// Holds the owning table plus a single fixed `commit_user`, generated once
and
-/// shared by both `new_write()` and `new_commit()` so that writers and the
-/// committer agree on the commit user (Paimon uses it for duplicate-commit
-/// detection). Creating a fresh `WriteBuilder` per call would otherwise mint a
-/// new random UUID each time.
-#[pyclass(name = "WriteBuilder", module = "pypaimon_rust.datafusion")]
-pub struct PyWriteBuilder {
+type PartitionSpec = HashMap<String, Option<Datum>>;
+type PythonPartitionSpec = HashMap<String, Py<PyAny>>;
+
+/// Shared implementation state; public builders keep batch and stream
contracts separate.
+struct WriteContext {
table: Arc<Table>,
commit_user: String,
}
-impl PyWriteBuilder {
- pub fn new(table: Arc<Table>) -> Self {
+impl WriteContext {
+ fn new(table: Arc<Table>) -> Self {
let commit_user = table.new_write_builder().commit_user().to_string();
Self { table, commit_user }
}
-}
-#[pymethods]
-impl PyWriteBuilder {
- /// Create a writer for accumulating Arrow batches.
- fn new_write(&self) -> PyResult<PyTableWrite> {
+ fn new_write(&self, overwrite: bool) -> PyResult<WriteState> {
let builder = self
.table
.new_write_builder()
.with_commit_user(self.commit_user.clone())
.map_err(to_py_err)?;
- let target_schema =
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
- .map_err(to_py_err)?;
- Ok(PyTableWrite {
- inner: builder.new_write().map_err(to_py_err)?,
- target_schema,
+ let builder = if overwrite {
+ builder.with_overwrite()
+ } else {
+ builder
+ };
+ Ok(WriteState {
+ inner: Some(builder.new_write().map_err(to_py_err)?),
+ target_schema:
paimon::arrow::build_target_arrow_schema(self.table.schema().fields())
+ .map_err(to_py_err)?,
table_location: self.table.location().to_string(),
commit_user: self.commit_user.clone(),
})
}
+}
- /// Create a committer for persisting prepared commit messages.
- fn new_commit(&self) -> PyResult<PyTableCommit> {
- let builder = self
+fn boolean_option(table: &Table, key: &str, default: bool) -> PyResult<bool> {
+ match table.schema().options().get(key) {
+ None => Ok(default),
+ Some(value) if value.eq_ignore_ascii_case("true") => Ok(true),
+ Some(value) if value.eq_ignore_ascii_case("false") => Ok(false),
+ Some(value) => Err(PyValueError::new_err(format!(
+ "Invalid boolean option {key}: {value}"
+ ))),
+ }
+}
+
+/// Java partition specs may encode numeric values as strings. Other Python
+/// literals use the same schema-driven conversion as the predicate API.
+fn partition_value(value: &Bound<'_, PyAny>, data_type: &DataType) ->
PyResult<Datum> {
+ if let Ok(text) = value.cast::<PyString>() {
+ let text = text.to_str()?;
+ let invalid = || {
+ PyValueError::new_err(format!(
+ "Invalid partition value '{text}' for {data_type:?}"
+ ))
+ };
+ match data_type {
+ DataType::TinyInt(_) => return
text.parse().map(Datum::TinyInt).map_err(|_| invalid()),
+ DataType::SmallInt(_) => {
+ return text.parse().map(Datum::SmallInt).map_err(|_| invalid())
+ }
+ DataType::Int(_) => return
text.parse().map(Datum::Int).map_err(|_| invalid()),
+ DataType::BigInt(_) => return
text.parse().map(Datum::Long).map_err(|_| invalid()),
+ DataType::Float(_) => return
text.parse().map(Datum::Float).map_err(|_| invalid()),
+ DataType::Double(_) => return
text.parse().map(Datum::Double).map_err(|_| invalid()),
+ _ => {}
+ }
+ }
+ py_to_datum(value, data_type)
+}
+
+fn partition_spec(
+ py: Python<'_>,
+ table: &Table,
+ spec: PythonPartitionSpec,
+) -> PyResult<PartitionSpec> {
+ let fields = table.schema().partition_fields();
+ let default_name = CoreOptions::new(table.schema().options())
+ .partition_default_name()
+ .to_string();
+ spec.into_iter()
+ .map(|(key, value)| {
+ let field = fields
+ .iter()
+ .find(|field| field.name() == key)
+ .ok_or_else(|| {
+ PyValueError::new_err(format!(
+ "Partition spec key '{key}' is not a partition column"
+ ))
+ })?;
+ let value = value.bind(py);
+ let is_default = value
+ .cast::<PyString>()
+ .is_ok_and(|s| s.to_str().is_ok_and(|s| s == default_name));
+ let datum = if value.is_none() || is_default {
+ None
+ } else {
+ Some(partition_value(value, field.data_type())?)
+ };
+ Ok((key, datum))
+ })
+ .collect()
+}
+
+/// Java BatchWriteBuilder: the overwrite spec configures both writer and
committer.
+#[pyclass(name = "BatchWriteBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyBatchWriteBuilder {
+ context: WriteContext,
+ static_partition: Option<PartitionSpec>,
+}
+
+impl PyBatchWriteBuilder {
+ pub fn new(table: Arc<Table>) -> Self {
+ Self {
+ context: WriteContext::new(table),
+ static_partition: None,
+ }
+ }
+}
+
+#[pymethods]
+impl PyBatchWriteBuilder {
+ /// No argument enables overwrite with an empty spec; explicit None
restores append.
+ #[pyo3(signature = (static_partition=Some(HashMap::new())))]
+ fn with_overwrite<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ py: Python<'py>,
+ static_partition: Option<PythonPartitionSpec>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ slf.static_partition = static_partition
+ .map(|spec| partition_spec(py, &slf.context.table, spec))
+ .transpose()?;
+ Ok(slf)
+ }
+
+ fn new_write(&self) -> PyResult<PyBatchTableWrite> {
+ Ok(PyBatchTableWrite {
+ state: self.context.new_write(self.static_partition.is_some())?,
+ prepared: false,
+ })
+ }
+
+ fn new_commit(&self) -> PyResult<PyBatchTableCommit> {
+ let table = &self.context.table;
+ let ignore_empty = boolean_option(table,
"snapshot.ignore-empty-commit", true)?;
+ let dynamic = boolean_option(table, "dynamic-partition-overwrite",
true)?
+ && !table.schema().partition_keys().is_empty();
+ Ok(PyBatchTableCommit {
+ context: CommitContext::new(table, &self.context.commit_user,
ignore_empty)?,
+ overwrite: self.static_partition.is_some(),
+ static_partition: if dynamic {
+ None
+ } else {
+ self.static_partition.clone()
+ },
+ committed: false,
+ })
+ }
+}
+
+/// Java StreamWriteBuilder: stable commit identity belongs on the builder.
+#[pyclass(name = "StreamWriteBuilder", module = "pypaimon_rust.datafusion")]
+pub struct PyStreamWriteBuilder {
+ context: WriteContext,
+}
+
+impl PyStreamWriteBuilder {
+ pub fn new(table: Arc<Table>) -> Self {
+ Self {
+ context: WriteContext::new(table),
+ }
+ }
+}
+
+#[pymethods]
+impl PyStreamWriteBuilder {
+ fn commit_user(&self) -> &str {
+ &self.context.commit_user
+ }
+
+ fn with_commit_user(
+ mut slf: PyRefMut<'_, Self>,
+ commit_user: String,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ slf.context
.table
.new_write_builder()
- .with_commit_user(self.commit_user.clone())
+ .with_commit_user(commit_user.clone())
.map_err(to_py_err)?;
- Ok(PyTableCommit {
- inner: builder.new_commit(),
- table_location: self.table.location().to_string(),
- commit_user: self.commit_user.clone(),
+ slf.context.commit_user = commit_user;
+ Ok(slf)
+ }
+
+ fn new_write(&self) -> PyResult<PyStreamTableWrite> {
+ Ok(PyStreamTableWrite {
+ state: self.context.new_write(false)?,
+ })
+ }
+
+ fn new_commit(&self) -> PyResult<PyStreamTableCommit> {
+ Ok(PyStreamTableCommit {
+ context: CommitContext::new(&self.context.table,
&self.context.commit_user, false)?,
})
}
}
-/// A stateful writer that accumulates Arrow batches until `prepare_commit`.
-///
-/// Marked `unsendable`: the underlying `TableWrite` holds file writers that
are
-/// not `Sync`, so the object enforces single-thread access at runtime.
-#[pyclass(name = "TableWrite", module = "pypaimon_rust.datafusion",
unsendable)]
-pub struct PyTableWrite {
- inner: TableWrite,
- /// The table's target Arrow schema, used to validate incoming batches.
+struct WriteState {
+ inner: Option<TableWrite>,
target_schema: Arc<ArrowSchema>,
- /// The owning table's location, stamped onto produced commit messages so a
- /// committer can reject messages prepared for a different table.
table_location: String,
- /// The originating builder's `commit_user`, stamped onto produced
messages so
- /// a committer can reject messages prepared by a different `WriteBuilder`
- /// (writers and committers from the same builder must share one
commit_user;
- /// it drives snapshot duplicate detection and postpone-bucket file
naming).
commit_user: String,
}
-#[pymethods]
-impl PyTableWrite {
- /// Write a single PyArrow RecordBatch into the table's writers.
+impl WriteState {
fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
let batch = RecordBatch::from_pyarrow_bound(batch)?;
validate_batch_schema(&batch.schema(), &self.target_schema)?;
- let rt = runtime();
- py.detach(|| rt.block_on(async {
self.inner.write_arrow_batch(&batch).await }))
+ let inner = self
+ .inner
+ .as_mut()
+ .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
+ py.detach(|| runtime().block_on(inner.write_arrow_batch(&batch)))
.map_err(to_py_err)
}
- /// Close writers and return the commit messages (opaque; pass to
commit()).
fn prepare_commit(&mut self, py: Python<'_>) ->
PyResult<Vec<PyCommitMessage>> {
- let rt = runtime();
+ let inner = self
+ .inner
+ .as_mut()
+ .ok_or_else(|| PyRuntimeError::new_err("TableWrite is closed"))?;
let messages = py
- .detach(|| rt.block_on(async { self.inner.prepare_commit().await
}))
+ .detach(|| runtime().block_on(inner.prepare_commit()))
.map_err(to_py_err)?;
Ok(messages
.into_iter()
.map(|inner| PyCommitMessage {
inner,
- table_location: self.table_location.clone(),
- commit_user: self.commit_user.clone(),
+ origin: Some(MessageOrigin {
+ table_location: self.table_location.clone(),
+ commit_user: self.commit_user.clone(),
+ }),
})
.collect())
}
}
-/// A committer that persists prepared commit messages as a snapshot.
-#[pyclass(name = "TableCommit", module = "pypaimon_rust.datafusion")]
-pub struct PyTableCommit {
+#[pyclass(
+ name = "BatchTableWrite",
+ module = "pypaimon_rust.datafusion",
+ unsendable
+)]
+pub struct PyBatchTableWrite {
+ state: WriteState,
+ prepared: bool,
+}
+
+#[pymethods]
+impl PyBatchTableWrite {
+ fn close(&mut self, py: Python<'_>) {
+ if let Some(mut writer) = self.state.inner.take() {
+ py.detach(|| runtime().block_on(writer.close()));
+ }
+ }
+
+ fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
+ self.state.write_arrow(py, batch)
+ }
+
+ fn prepare_commit(&mut self, py: Python<'_>) ->
PyResult<Vec<PyCommitMessage>> {
+ if self.prepared {
+ return Err(PyRuntimeError::new_err(
+ "BatchTableWrite only supports one-time committing.",
+ ));
+ }
+ self.prepared = true;
+ self.state.prepare_commit(py)
+ }
+}
+
+#[pyclass(
+ name = "StreamTableWrite",
+ module = "pypaimon_rust.datafusion",
+ unsendable
+)]
+pub struct PyStreamTableWrite {
+ state: WriteState,
+}
+
+#[pymethods]
+impl PyStreamTableWrite {
+ fn close(&mut self, py: Python<'_>) {
+ if let Some(mut writer) = self.state.inner.take() {
+ py.detach(|| runtime().block_on(writer.close()));
+ }
+ }
+
+ fn write_arrow(&mut self, py: Python<'_>, batch: &Bound<'_, PyAny>) ->
PyResult<()> {
+ self.state.write_arrow(py, batch)
+ }
+
+ /// Rust currently flushes synchronously and has no background compaction.
+ /// The identifier accompanies the returned messages in
StreamTableCommit.commit.
+ fn prepare_commit(
+ &mut self,
+ py: Python<'_>,
+ wait_compaction: bool,
+ commit_identifier: i64,
+ ) -> PyResult<Vec<PyCommitMessage>> {
+ let _ = (wait_compaction, commit_identifier);
+ self.state.prepare_commit(py)
+ }
+}
+
+struct CommitContext {
inner: TableCommit,
- /// The owning table's location, used to reject commit messages that were
- /// prepared for a different table (which would otherwise persist a
snapshot
- /// referencing data files written under another table).
- table_location: String,
- /// The committer's `commit_user`, used to reject messages prepared by a
- /// different `WriteBuilder` — even for the same table — since the writer
and
- /// committer must share one commit_user.
+ table: Arc<Table>,
commit_user: String,
}
+impl CommitContext {
+ fn new(table: &Arc<Table>, commit_user: &str, ignore_empty: bool) ->
PyResult<Self> {
+ let inner = table
+ .new_write_builder()
+ .with_commit_user(commit_user)
+ .map_err(to_py_err)?
+ .try_new_commit()
+ .map_err(to_py_err)?
+ .with_ignore_empty_commit(ignore_empty);
+ Ok(Self {
+ inner,
+ table: Arc::clone(table),
+ commit_user: commit_user.to_string(),
+ })
+ }
+
+ fn messages(
+ &self,
+ messages: &Bound<'_, PyAny>,
+ method: &str,
+ overwrite: bool,
+ ) -> PyResult<Vec<CommitMessage>> {
+ collect_and_validate_messages(
+ messages,
+ self.table.location(),
+ &self.commit_user,
+ method,
+ overwrite,
+ )
+ }
+
+ fn abort(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
+ let messages = self.messages(messages, "abort", false)?;
+ py.detach(|| runtime().block_on(self.inner.abort(&messages)))
+ .map_err(to_py_err)
+ }
+}
+
+#[pyclass(name = "BatchTableCommit", module = "pypaimon_rust.datafusion")]
+pub struct PyBatchTableCommit {
+ context: CommitContext,
+ overwrite: bool,
+ static_partition: Option<PartitionSpec>,
+ committed: bool,
+}
+
+impl PyBatchTableCommit {
+ fn check_committed(&mut self) -> PyResult<()> {
+ if self.committed {
+ return Err(PyRuntimeError::new_err(
+ "BatchTableCommit only supports one-time committing.",
+ ));
+ }
+ self.committed = true;
+ Ok(())
+ }
+}
+
+#[pymethods]
+impl PyBatchTableCommit {
+ /// Rust committers have no background resources to shut down.
+ fn close(&self) {}
+
+ fn commit(&mut self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
+ let messages = self.context.messages(messages, "commit",
self.overwrite)?;
+ self.check_committed()?;
+ py.detach(|| {
+ runtime().block_on(async {
+ if self.overwrite {
+ self.context
+ .inner
+ .overwrite(messages, self.static_partition.clone())
+ .await
+ } else {
+ self.context.inner.commit(messages).await
+ }
+ })
+ })
+ .map_err(to_py_err)
+ }
+
+ fn truncate_table(&mut self, py: Python<'_>) -> PyResult<()> {
+ self.check_committed()?;
+ py.detach(|| runtime().block_on(self.context.inner.truncate_table()))
+ .map_err(to_py_err)
+ }
+
+ fn truncate_partitions(
+ &self,
+ py: Python<'_>,
+ partitions: Vec<PythonPartitionSpec>,
+ ) -> PyResult<()> {
+ let partitions = partitions
+ .into_iter()
+ .map(|spec| partition_spec(py, &self.context.table, spec))
+ .collect::<PyResult<Vec<_>>>()?;
+ py.detach(||
runtime().block_on(self.context.inner.drop_partitions(partitions)))
+ .map_err(to_py_err)
+ }
+
+ fn abort(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
+ self.context.abort(py, messages)
+ }
+}
+
+#[pyclass(name = "StreamTableCommit", module = "pypaimon_rust.datafusion")]
+pub struct PyStreamTableCommit {
+ context: CommitContext,
+}
+
+#[pymethods]
+impl PyStreamTableCommit {
+ /// Rust committers have no background resources to shut down.
+ fn close(&self) {}
+
+ fn commit(
+ &self,
+ py: Python<'_>,
+ commit_identifier: i64,
+ messages: &Bound<'_, PyAny>,
+ ) -> PyResult<()> {
+ let messages = self.context.messages(messages, "commit", false)?;
+ py.detach(|| {
+ runtime().block_on(
+ self.context
+ .inner
+ .commit_with_identifier(messages, commit_identifier),
+ )
+ })
+ .map_err(to_py_err)
+ }
+
+ /// Sort identifiers, filter already committed groups, and return the
number committed.
+ fn filter_and_commit(
+ &self,
+ py: Python<'_>,
+ commit_identifiers_and_messages: &Bound<'_, PyDict>,
+ ) -> PyResult<usize> {
+ let commits = commit_identifiers_and_messages
+ .iter()
+ .map(|(id, messages)| {
+ Ok((
+ id.extract::<i64>()?,
+ self.context
+ .messages(&messages, "filter_and_commit", false)?,
+ ))
+ })
+ .collect::<PyResult<Vec<_>>>()?;
+ py.detach(||
runtime().block_on(self.context.inner.filter_and_commit(commits)))
+ .map_err(to_py_err)
+ }
+
+ fn abort(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
+ self.context.abort(py, messages)
+ }
+}
+
/// Collect and validate commit messages from a Python iterable, returning the
-/// inner Rust `CommitMessage` values. Shared by `commit` and `abort`.
+/// inner Rust `CommitMessage` values for every operation accepting messages.
fn collect_and_validate_messages<'py>(
messages: &Bound<'py, PyAny>,
table_location: &str,
commit_user: &str,
method: &str,
+ overwrite: bool,
) -> PyResult<Vec<CommitMessage>> {
let mut inner_messages = Vec::new();
let iter = messages.try_iter().map_err(|_| {
@@ -196,68 +565,61 @@ fn collect_and_validate_messages<'py>(
"{method}() expects a sequence of CommitMessage objects"
))
})?;
- if msg.table_location != table_location {
- return Err(PyValueError::new_err(format!(
- "commit message was prepared for a different table \
- (message table '{}', committer table '{}')",
- msg.table_location, table_location
- )));
+ let mut inner = msg.inner.clone();
+ if let Some(origin) = &msg.origin {
+ if origin.table_location != table_location {
+ return Err(PyValueError::new_err(format!(
+ "commit message was prepared for a different table \
+ (message table '{}', committer table '{}')",
+ origin.table_location, table_location
+ )));
+ }
+ if origin.commit_user != commit_user {
+ return Err(PyValueError::new_err(
+ "commit message has a different commit_user \
+ (writer and committer must share one commit_user)"
+ .to_string(),
+ ));
+ }
+ } else if overwrite {
+ // The Java body has no operation flag. Apply the target
committer's
+ // mode to imported messages without changing the Python object.
+ inner.mark_fixed_bucket_overwrite();
}
- if msg.commit_user != commit_user {
- return Err(PyValueError::new_err(
- "commit message was prepared by a different WriteBuilder \
- (writer and committer must come from the same \
- table.new_write_builder() so they share one commit_user)"
- .to_string(),
- ));
- }
- inner_messages.push(msg.inner.clone());
+ inner_messages.push(inner);
}
Ok(inner_messages)
}
-#[pymethods]
-impl PyTableCommit {
- /// Commit the given commit messages. Empty input is a no-op success.
- fn commit(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
- let inner_messages = collect_and_validate_messages(
- messages,
- &self.table_location,
- &self.commit_user,
- "commit",
- )?;
- let rt = runtime();
- py.detach(|| rt.block_on(async {
self.inner.commit(inner_messages).await }))
- .map_err(to_py_err)
- }
-
- /// Abort a prepared commit by deleting newly written data, changelog and
- /// index files. Deletion is best-effort: missing files or storage errors
- /// are silently ignored so abort cleanup never masks the original write
- /// failure. After abort the data must not be committed — the files no
- /// longer exist.
- fn abort(&self, py: Python<'_>, messages: &Bound<'_, PyAny>) ->
PyResult<()> {
- let inner_messages = collect_and_validate_messages(
- messages,
- &self.table_location,
- &self.commit_user,
- "abort",
- )?;
- let rt = runtime();
- py.detach(|| rt.block_on(async {
self.inner.abort(&inner_messages).await }))
- .map_err(to_py_err)
- }
+/// Origin information retained for messages returned directly by a local
writer.
+struct MessageOrigin {
+ table_location: String,
+ commit_user: String,
}
-/// An opaque commit message produced by `prepare_commit`, consumed by
`commit`.
-/// PR1 supports same-process transfer only (no pickle/serialization).
-///
-/// Carries the originating table's location and builder `commit_user` so a
-/// committer can reject messages prepared for a different table or by a
-/// different `WriteBuilder`.
+/// A commit message produced by `prepare_commit` or decoded from the Java v14
body.
+/// Serialized messages contain no table, commit user, or operation context.
#[pyclass(name = "CommitMessage", module = "pypaimon_rust.datafusion")]
pub struct PyCommitMessage {
- pub(crate) inner: CommitMessage,
- pub(crate) table_location: String,
- pub(crate) commit_user: String,
+ inner: CommitMessage,
+ origin: Option<MessageOrigin>,
+}
+
+#[pymethods]
+impl PyCommitMessage {
+ /// Export the Java `CommitMessageSerializer` v14 body (no version header).
+ fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyBytes>>
{
+ let bytes = self.inner.serialize().map_err(to_py_err)?;
+ Ok(PyBytes::new(py, &bytes))
+ }
+
+ /// Decode a Java body independently of a table or write builder.
+ #[staticmethod]
+ #[pyo3(signature = (data, *, version=COMMIT_MESSAGE_SERIALIZER_VERSION))]
+ fn deserialize(data: &Bound<'_, PyBytes>, version: i32) -> PyResult<Self> {
+ Ok(Self {
+ inner: CommitMessage::deserialize(version,
data.as_bytes()).map_err(to_py_err)?,
+ origin: None,
+ })
+ }
}
diff --git a/bindings/python/tests/test_table_commit.py
b/bindings/python/tests/test_table_commit.py
new file mode 100644
index 00000000..b168b224
--- /dev/null
+++ b/bindings/python/tests/test_table_commit.py
@@ -0,0 +1,415 @@
+# 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.
+
+import base64
+import json
+from pathlib import Path
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon_rust import datafusion
+from pypaimon_rust.datafusion import CommitMessage, PaimonCatalog, SQLContext
+
+
+def _table(path, partitioned=False, options=None, primary_key=False):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": str(path)})
+ ctx.sql("CREATE SCHEMA paimon.db")
+ ddl = "CREATE TABLE paimon.db.t (id INT, pt INT"
+ ddl += ", PRIMARY KEY (id))" if primary_key else ")"
+ if partitioned:
+ ddl += " PARTITIONED BY (pt)"
+ if options:
+ ddl += " WITH (" + ", ".join(f"'{k}' = '{v}'" for k, v in
options.items()) + ")"
+ ctx.sql(ddl)
+ return PaimonCatalog({"warehouse": str(path)}).get_table("db.t")
+
+
+def _write(writer, ids, partitions):
+ writer.write_arrow(pa.record_batch(
+ [ids, partitions], schema=pa.schema([("id", pa.int32()), ("pt",
pa.int32())])
+ ))
+
+
+def _prepare(builder, ids, partitions, identifier=None):
+ writer = builder.new_write()
+ _write(writer, ids, partitions)
+ return writer.prepare_commit() if identifier is None else
writer.prepare_commit(True, identifier)
+
+
+def _append(table, ids, partitions):
+ builder = table.new_batch_write_builder()
+ builder.new_commit().commit(_prepare(builder, ids, partitions))
+
+
+def _roundtrip(messages):
+ return [CommitMessage.deserialize(message.serialize()) for message in
messages]
+
+
+def _rows(table):
+ reader = table.new_read_builder()
+ batches = reader.new_read().read(reader.new_scan().plan().splits())
+ return sorted(row["id"] for batch in batches for row in batch.to_pylist())
+
+
+def _snapshot(table):
+ return json.loads((Path(table.location()) / "snapshot" /
+ f"snapshot-{table.latest_snapshot().id()}").read_text())
+
+
+def test_public_api_separates_batch_and_stream(tmp_path):
+ table = _table(tmp_path)
+ assert not hasattr(table, "new_commit")
+ assert not hasattr(table, "new_write_builder")
+ for name in ("WriteBuilder", "TableWrite", "TableCommit"):
+ assert not hasattr(datafusion, name)
+ for factory in (table.new_batch_write_builder,
table.new_stream_write_builder):
+ for kwargs in ({"commit_user": "job"}, {"overwrite": True}):
+ with pytest.raises(TypeError):
+ factory(**kwargs)
+ batch = table.new_batch_write_builder()
+ stream = table.new_stream_write_builder()
+ assert not hasattr(batch, "with_commit_user")
+ assert not hasattr(stream, "with_overwrite")
+ for obj in (batch, stream, batch.new_commit(), stream.new_commit()):
+ assert not hasattr(obj, "deserialize_commit_message")
+ assert not hasattr(batch.new_commit(), "filter_and_commit")
+ assert not hasattr(batch.new_commit(), "overwrite")
+ assert not hasattr(stream.new_commit(), "truncate_table")
+ with pytest.raises(TypeError):
+ batch.new_commit().commit([], commit_identifier=1)
+ with pytest.raises(TypeError):
+ stream.new_commit().commit([])
+
+
+def test_serialized_stream_commit_and_grouped_retry(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder()
+ assert builder.with_commit_user("python-job") is builder
+ assert builder.commit_user() == "python-job"
+ writer = builder.new_write()
+ commit = builder.new_commit()
+ _write(writer, [1], [10])
+ first = _roundtrip(writer.prepare_commit(True, 7))
+ commit.commit(7, first)
+ _write(writer, [2], [20])
+ second = _roundtrip(writer.prepare_commit(False, 8))
+ _write(writer, [3], [30])
+ third = _roundtrip(writer.prepare_commit(True, 9))
+ restored =
table.new_stream_write_builder().with_commit_user("python-job").new_commit()
+ # Input order differs from commit order; count groups after filtering.
+ assert restored.filter_and_commit({9: third, 7: first, 8: second}) == 2
+ assert _rows(table) == [1, 2, 3]
+ snapshot = _snapshot(table)
+ assert snapshot["commitUser"] == "python-job"
+ assert snapshot["commitIdentifier"] == 9
+ assert restored.filter_and_commit({7: first, 8: second, 9: third}) == 0
+ assert table.latest_snapshot().id() == snapshot["id"]
+ assert restored.filter_and_commit({}) == 0
+
+
[email protected]("user", ["", "../job", "a/b"])
+def test_invalid_stream_commit_user(tmp_path, user):
+ builder = _table(tmp_path).new_stream_write_builder()
+ original = builder.commit_user()
+ with pytest.raises(ValueError):
+ builder.with_commit_user(user)
+ assert builder.commit_user() == original
+
+
+def test_static_deserialize_checks_version_and_payload():
+ # Java CommitMessageSerializer v14 fixture; no table or builder is needed.
+ body = base64.b64decode(
+
"AAAADAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABw=="
+ )
+ with pytest.raises(NotImplementedError, match="version"):
+ CommitMessage.deserialize(body, version=13)
+ for invalid in (b"", body[:-1], body + b"extra"):
+ with pytest.raises(ValueError):
+ CommitMessage.deserialize(invalid)
+ assert CommitMessage.deserialize(body).serialize() == body
+ assert CommitMessage.deserialize(body, version=14).serialize() == body
+
+
+def test_deserialized_batch_commit_uses_target_builder(tmp_path):
+ table = _table(tmp_path)
+ messages = _roundtrip(_prepare(table.new_batch_write_builder(), [1], [10]))
+ table.new_batch_write_builder().new_commit().commit(messages)
+ assert _rows(table) == [1]
+
+
+def test_abort_serialized_messages_deletes_files(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ messages = _roundtrip(_prepare(builder, [1], [10]))
+ files = list(tmp_path.rglob("data-*.parquet"))
+ assert files
+ builder.new_commit().abort(messages)
+ assert not any(file.exists() for file in files)
+ assert table.latest_snapshot() is None
+
+
+def test_batch_writer_and_commit_are_one_shot(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ _write(writer, [1], [10])
+ messages = writer.prepare_commit()
+ with pytest.raises(RuntimeError, match="one-time"):
+ writer.prepare_commit()
+ commit = builder.new_commit()
+ commit.commit(messages)
+ with pytest.raises(RuntimeError, match="one-time"):
+ commit.commit(messages)
+ with pytest.raises(RuntimeError, match="one-time"):
+ commit.truncate_table()
+ assert _rows(table) == [1]
+ truncate = builder.new_commit()
+ truncate.truncate_table()
+ with pytest.raises(RuntimeError, match="one-time"):
+ truncate.commit([])
+ assert _rows(table) == []
+
+
[email protected]("ignore", ["true", "false", "False"])
+def test_batch_empty_commit_honors_option(tmp_path, ignore):
+ table = _table(tmp_path, options={"snapshot.ignore-empty-commit": ignore})
+ commit = table.new_batch_write_builder().new_commit()
+ commit.commit([])
+ assert (table.latest_snapshot() is None) == (ignore.lower() == "true")
+ with pytest.raises(RuntimeError, match="one-time"):
+ commit.commit([])
+
+
+def test_stream_empty_checkpoint_is_recorded(tmp_path):
+ table = _table(tmp_path, options={"snapshot.ignore-empty-commit": "true"})
+ commit =
table.new_stream_write_builder().with_commit_user("job").new_commit()
+ commit.commit(1, [])
+ assert _snapshot(table)["commitIdentifier"] == 1
+ assert commit.filter_and_commit({3: [], 1: [], 2: []}) == 2
+ assert _snapshot(table)["commitIdentifier"] == 3
+ assert table.latest_snapshot().id() == 3
+ assert _rows(table) == []
+
+
[email protected]("spec", [{}, {"pt": 999}])
+def test_default_dynamic_overwrite_uses_touched_partitions(tmp_path, spec):
+ table = _table(tmp_path, partitioned=True)
+ _append(table, [1, 2], [10, 20])
+ builder = table.new_batch_write_builder()
+ assert builder.with_overwrite(spec) is builder
+ builder.new_commit().commit(_roundtrip(_prepare(builder, [3], [10])))
+ assert _rows(table) == [2, 3]
+ table.new_batch_write_builder().with_overwrite().new_commit().commit([])
+ assert _rows(table) == [2, 3]
+
+
[email protected]("null_value", [None, "__DEFAULT_PARTITION__"])
+def test_static_overwrite_and_empty_truncation(tmp_path, null_value):
+ table = _table(tmp_path, partitioned=True,
options={"dynamic-partition-overwrite": "FALSE"})
+ _append(table, [1, 2, 3], [10, 20, None])
+ builder = table.new_batch_write_builder().with_overwrite({"pt": 10})
+ builder.new_commit().commit(_prepare(builder, [4], [10]))
+ assert _rows(table) == [2, 3, 4]
+ table.new_batch_write_builder().with_overwrite({"pt":
null_value}).new_commit().commit([])
+ assert _rows(table) == [2, 4]
+ table.new_batch_write_builder().with_overwrite().new_commit().commit([])
+ assert _rows(table) == []
+
+
+def test_unpartitioned_empty_overwrite_truncates(tmp_path):
+ table = _table(tmp_path)
+ _append(table, [1], [10])
+ table.new_batch_write_builder().with_overwrite().new_commit().commit([])
+ assert _rows(table) == []
+
+
+def test_explicit_none_disables_overwrite_and_context_is_copied(tmp_path):
+ table = _table(tmp_path)
+ _append(table, [1], [10])
+ builder = table.new_batch_write_builder().with_overwrite()
+ overwrite = builder.new_commit()
+ builder.with_overwrite(None)
+ builder.new_commit().commit(_prepare(builder, [2], [20]))
+ assert _rows(table) == [1, 2]
+ overwrite.commit([])
+ assert _rows(table) == []
+
+
+def test_static_deserialize_uses_committer_overwrite_mode(tmp_path):
+ table = _table(tmp_path)
+ _append(table, [1], [10])
+ builder = table.new_batch_write_builder().with_overwrite()
+ body = _prepare(builder, [2], [20])[0].serialize()
+ # Model an external message carrying Java totalBuckets.
+ flag_offset = 4 + int.from_bytes(body[:4], "big") + 4
+ assert body[flag_offset] == 0
+ body = body[:flag_offset] + bytes([1]) + (1).to_bytes(4, "big") +
body[flag_offset + 1:]
+ commit = builder.new_commit()
+ message = CommitMessage.deserialize(body)
+ assert message.serialize() == body
+ commit.commit([message])
+ assert _rows(table) == [2]
+
+
+def test_overwrite_does_not_mutate_deserialized_message(tmp_path):
+ table = _table(tmp_path, partitioned=True,
options={"dynamic-partition-overwrite": "false"})
+ _append(table, [1], [10])
+ body = _prepare(table.new_batch_write_builder(), [2], [20])[0].serialize()
+ flag_offset = 4 + int.from_bytes(body[:4], "big") + 4
+ assert body[flag_offset] == 0
+ body = body[:flag_offset] + bytes([1]) + (1).to_bytes(4, "big") +
body[flag_offset + 1:]
+ message = CommitMessage.deserialize(body)
+ overwrite = table.new_batch_write_builder().with_overwrite({"pt":
10}).new_commit()
+ with pytest.raises(ValueError, match="does not belong"):
+ overwrite.commit([message])
+ # A failed overwrite must not stamp the object with its operation mode.
+ table.new_batch_write_builder().new_commit().commit([message])
+ assert _rows(table) == [1, 2]
+
+
+def test_failed_batch_commit_consumes_instance(tmp_path):
+ table = _table(tmp_path, partitioned=True,
options={"dynamic-partition-overwrite": "FALSE"})
+ _append(table, [1], [10])
+ builder = table.new_batch_write_builder().with_overwrite({"pt": 20})
+ messages = _prepare(builder, [2], [10])
+ commit = builder.new_commit()
+ with pytest.raises(ValueError, match="does not belong"):
+ commit.commit(messages)
+ with pytest.raises(RuntimeError, match="one-time"):
+ commit.commit(messages)
+ assert _rows(table) == [1]
+ assert len(list(tmp_path.rglob("data-*.parquet"))) == 2
+
+
+def test_partition_truncation_matches_java_lifecycle(tmp_path):
+ table = _table(tmp_path, partitioned=True)
+ _append(table, [1, 2], [10, 20])
+ commit = table.new_batch_write_builder().new_commit()
+ with pytest.raises(ValueError, match="Partitions list cannot be empty"):
+ commit.truncate_partitions([])
+ commit.truncate_partitions([{"pt": 10}])
+ assert _rows(table) == [2]
+ # Java truncatePartitions does not consume the batch commit guard.
+ commit.commit([])
+ commit.truncate_partitions([{"pt": 20}])
+ assert _rows(table) == []
+
+
[email protected]("spec", [{"id": 1}, {"pt": "bad"}, {"pt": True}])
+def test_partition_spec_validation_precedes_mutation(tmp_path, spec):
+ table = _table(tmp_path, partitioned=True)
+ _append(table, [1], [10])
+ snapshot_id = table.latest_snapshot().id()
+ with pytest.raises(ValueError):
+ table.new_batch_write_builder().with_overwrite(spec)
+ with pytest.raises(ValueError):
+
table.new_batch_write_builder().new_commit().truncate_partitions([spec])
+ assert table.latest_snapshot().id() == snapshot_id
+ assert _rows(table) == [1]
+
+
+def test_recovery_checks_all_pending_files_before_any_commit(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder().with_commit_user("job")
+ first = _prepare(builder, [1], [10], 1)
+ before = set(tmp_path.rglob("data-*.parquet"))
+ second = _prepare(builder, [2], [20], 2)
+ for path in set(tmp_path.rglob("data-*.parquet")) - before:
+ path.unlink()
+ with pytest.raises(ValueError, match="does not exist"):
+ builder.new_commit().filter_and_commit({1: first, 2: second})
+ assert table.latest_snapshot() is None
+
+
+def test_recovery_skips_file_check_for_committed_checkpoints(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder().with_commit_user("job")
+ messages = _prepare(builder, [1], [10], 1)
+ commit = builder.new_commit()
+ commit.commit(1, messages)
+ # Model retention after a later overwrite: a filtered checkpoint can
reference expired files.
+ for path in tmp_path.rglob("data-*.parquet"):
+ path.unlink()
+ assert commit.filter_and_commit({1: messages}) == 0
+ assert table.latest_snapshot().id() == 1
+
+
[email protected]("operation", ["overwrite", "truncate"])
+def test_empty_destructive_batch_operation_records_snapshot(tmp_path,
operation):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ if operation == "overwrite":
+ builder.with_overwrite().new_commit().commit([])
+ else:
+ builder.new_commit().truncate_table()
+ assert _snapshot(table)["commitKind"] == "OVERWRITE"
+ assert _rows(table) == []
+
+
[email protected]("mode", ["batch", "stream"])
+def test_close_writer_preserves_prepared_files(tmp_path, mode):
+ table = _table(tmp_path)
+ builder = getattr(table, f"new_{mode}_write_builder")()
+ writer = builder.new_write()
+ _write(writer, [1], [10])
+ messages = writer.prepare_commit() if mode == "batch" else
writer.prepare_commit(True, 1)
+ writer.close()
+ writer.close()
+ with pytest.raises(RuntimeError, match="closed"):
+ _write(writer, [2], [20])
+ commit = builder.new_commit()
+ if mode == "batch":
+ commit.commit(messages)
+ else:
+ commit.commit(1, messages)
+ commit.close()
+ assert _rows(table) == [1]
+
+
+def test_static_overwrite_accepts_java_numeric_partition_strings(tmp_path):
+ table = _table(tmp_path, partitioned=True,
options={"dynamic-partition-overwrite": "false"})
+ _append(table, [1, 2], [10, 20])
+ table.new_batch_write_builder().with_overwrite({"pt":
"10"}).new_commit().commit([])
+ assert _rows(table) == [2]
+
+
[email protected]("bucket", [None, "1", "-2"])
+def test_close_cleans_unprepared_output_but_preserves_prepared_files(tmp_path,
bucket):
+ options = {"target-file-size": "1 b", "write.parquet-buffer-size": "1 b"}
+ if bucket is not None:
+ options["bucket"] = bucket
+ table = _table(tmp_path, options=options, primary_key=bucket is not None)
+ builder = table.new_stream_write_builder()
+ writer = builder.new_write()
+ _write(writer, [1], [10])
+ messages = writer.prepare_commit(True, 1)
+ prepared_paths = set(tmp_path.rglob("*.parquet"))
+ assert prepared_paths
+ _write(writer, [2], [20])
+ _write(writer, [3], [30])
+ assert set(tmp_path.rglob("*.parquet")) - prepared_paths
+ writer.close()
+ assert set(tmp_path.rglob("*.parquet")) == prepared_paths
+ builder.new_commit().commit(1, messages)
+ assert _snapshot(table)["totalRecordCount"] == 1
+ assert [row["id"] for path in prepared_paths
+ for row in pq.ParquetFile(path).read(columns=["id"]).to_pylist()]
== [1]
+ if bucket != "-2":
+ assert _rows(table) == [1]
diff --git a/bindings/python/tests/test_write.py
b/bindings/python/tests/test_write.py
index 98d9274d..f36fbfdd 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -52,7 +52,7 @@ def test_write_commit_read_roundtrip():
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
batch = _batch([1, 2, 3], ["a", "b", "c"])
- wb = table.new_write_builder()
+ wb = table.new_batch_write_builder()
write = wb.new_write()
write.write_arrow(batch)
messages = write.prepare_commit()
@@ -68,7 +68,7 @@ def test_write_multiple_batches():
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
- wb = table.new_write_builder()
+ wb = table.new_batch_write_builder()
write = wb.new_write()
write.write_arrow(_batch([1], ["a"]))
write.write_arrow(_batch([2], ["b"]))
@@ -84,7 +84,7 @@ def test_prepare_commit_returns_messages():
with tempfile.TemporaryDirectory() as warehouse:
_make_empty_table(warehouse)
table = _get_table(warehouse)
- write = table.new_write_builder().new_write()
+ write = table.new_batch_write_builder().new_write()
write.write_arrow(_batch([1], ["a"]))
messages = write.prepare_commit()
assert len(messages) >= 1
@@ -95,7 +95,7 @@ def test_commit_empty_messages_noop():
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
- wb = table.new_write_builder()
+ wb = table.new_batch_write_builder()
messages = wb.new_write().prepare_commit() # no write
assert messages == []
wb.new_commit().commit(messages) # no-op success
@@ -107,7 +107,7 @@ def test_write_arrow_type_mismatch_raises():
with tempfile.TemporaryDirectory() as warehouse:
_make_empty_table(warehouse) # table (id INT, name STRING)
table = _get_table(warehouse)
- write = table.new_write_builder().new_write()
+ write = table.new_batch_write_builder().new_write()
bad = pa.record_batch([["x", "y"], ["a", "b"]], names=["id", "name"])
# id as STRING
with pytest.raises(ValueError):
write.write_arrow(bad)
@@ -123,7 +123,7 @@ def test_write_arrow_binary_family_mismatch_raises():
ctx.sql("CREATE SCHEMA paimon.wdb")
ctx.sql("CREATE TABLE paimon.wdb.bt (id INT, data BINARY)")
table = PaimonCatalog({"warehouse": warehouse}).get_table("wdb.bt")
- write = table.new_write_builder().new_write()
+ write = table.new_batch_write_builder().new_write()
schema = pa.schema([("id", pa.int32()), ("data", pa.large_binary())])
bad = pa.record_batch([[1], [b"x"]], schema=schema)
with pytest.raises(ValueError):
@@ -135,10 +135,10 @@ def test_commit_non_message_raises_typeerror():
_make_empty_table(warehouse)
table = _get_table(warehouse)
with pytest.raises(TypeError):
- table.new_write_builder().new_commit().commit([object()])
+ table.new_batch_write_builder().new_commit().commit([object()])
# A non-iterable argument also raises TypeError (not a raw PyO3 error).
with pytest.raises(TypeError):
- table.new_write_builder().new_commit().commit(42)
+ table.new_batch_write_builder().new_commit().commit(42)
def test_commit_cross_table_messages_raises():
@@ -159,26 +159,26 @@ def test_commit_cross_table_messages_raises():
[pa.array([1], pa.int32()), pa.array(["a"], pa.string())],
names=["id", "name"],
)
- w1 = t1.new_write_builder().new_write()
+ w1 = t1.new_batch_write_builder().new_write()
w1.write_arrow(batch)
messages = w1.prepare_commit()
with pytest.raises(ValueError):
- t2.new_write_builder().new_commit().commit(messages)
+ t2.new_batch_write_builder().new_commit().commit(messages)
def test_commit_different_builder_same_table_raises():
- # Even for the same table, a committer from a different WriteBuilder must
+ # Even for the same table, a committer from a different BatchWriteBuilder
must
# reject the messages: each builder mints its own commit_user, and writers
# and committers must share one (snapshot duplicate detection / postpone
# bucket file naming depend on it).
with tempfile.TemporaryDirectory() as warehouse:
_make_empty_table(warehouse)
table = _get_table(warehouse)
- write = table.new_write_builder().new_write()
+ write = table.new_batch_write_builder().new_write()
write.write_arrow(_batch([1], ["a"]))
messages = write.prepare_commit()
with pytest.raises(ValueError):
- table.new_write_builder().new_commit().commit(messages)
+ table.new_batch_write_builder().new_commit().commit(messages)
def test_abort_cleans_up_written_data():
@@ -187,7 +187,7 @@ def test_abort_cleans_up_written_data():
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
- wb = table.new_write_builder()
+ wb = table.new_batch_write_builder()
write = wb.new_write()
write.write_arrow(_batch([1, 2, 3], ["a", "b", "c"]))
messages = write.prepare_commit()
@@ -202,7 +202,7 @@ def test_abort_empty_messages_noop():
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
- wb = table.new_write_builder()
+ wb = table.new_batch_write_builder()
messages = wb.new_write().prepare_commit() # no write
assert messages == []
wb.new_commit().abort(messages) # no-op success
@@ -215,9 +215,9 @@ def test_abort_non_message_raises_typeerror():
_make_empty_table(warehouse)
table = _get_table(warehouse)
with pytest.raises(TypeError):
- table.new_write_builder().new_commit().abort([object()])
+ table.new_batch_write_builder().new_commit().abort([object()])
with pytest.raises(TypeError):
- table.new_write_builder().new_commit().abort(42)
+ table.new_batch_write_builder().new_commit().abort(42)
def test_abort_cross_table_messages_raises():
@@ -234,22 +234,22 @@ def test_abort_cross_table_messages_raises():
[pa.array([1], pa.int32()), pa.array(["a"], pa.string())],
names=["id", "name"],
)
- w1 = t1.new_write_builder().new_write()
+ w1 = t1.new_batch_write_builder().new_write()
w1.write_arrow(batch)
messages = w1.prepare_commit()
with pytest.raises(ValueError):
- t2.new_write_builder().new_commit().abort(messages)
+ t2.new_batch_write_builder().new_commit().abort(messages)
def test_abort_different_builder_same_table_raises():
with tempfile.TemporaryDirectory() as warehouse:
_make_empty_table(warehouse)
table = _get_table(warehouse)
- write = table.new_write_builder().new_write()
+ write = table.new_batch_write_builder().new_write()
write.write_arrow(_batch([1], ["a"]))
messages = write.prepare_commit()
with pytest.raises(ValueError):
- table.new_write_builder().new_commit().abort(messages)
+ table.new_batch_write_builder().new_commit().abort(messages)
@pytest.mark.parametrize("legacy,precision,unit,value,directory", [
@@ -270,7 +270,7 @@ def
test_timestamp_partition_writes_and_reads_use_java_paths(tmp_path, legacy, p
schema = pa.schema([("id", pa.int32()), ("ts", pa.timestamp(unit))])
for row_id in [1, 2]:
table = _get_table(str(tmp_path))
- builder = table.new_write_builder()
+ builder = table.new_batch_write_builder()
write = builder.new_write()
write.write_arrow(pa.record_batch([[row_id], [value]], schema=schema))
builder.new_commit().commit(write.prepare_commit())
@@ -304,7 +304,7 @@ def test_ltz_schema_alias_write_roundtrip(tmp_path,
precision, unit, micros, fra
)
value = datetime(2026, 9, 15, 20, 0, 0, micros, tzinfo=timezone.utc)
arrow_schema = pa.schema([("id", pa.int32()), ("ts", pa.timestamp(unit,
tz="UTC"))])
- builder = table.new_write_builder()
+ builder = table.new_batch_write_builder()
write = builder.new_write()
write.write_arrow(pa.record_batch([[1], [value]], schema=arrow_schema))
builder.new_commit().commit(write.prepare_commit())
diff --git a/crates/paimon/src/spec/index_file_meta.rs
b/crates/paimon/src/spec/index_file_meta.rs
index 7af98412..278f3fd6 100644
--- a/crates/paimon/src/spec/index_file_meta.rs
+++ b/crates/paimon/src/spec/index_file_meta.rs
@@ -18,8 +18,59 @@
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
+use crate::spec::{
+ deserialize_binary_array_int, deserialize_binary_array_rows, BinaryRow,
BinaryRowBuilder,
+};
use indexmap::IndexMap;
+fn invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+fn round_to_word(size: usize) -> usize {
+ (size + 7) & !7
+}
+
+fn array_header(size: usize) -> usize {
+ 4 + size.div_ceil(32) * 4
+}
+
+fn serialize_int_array(values: &[i32]) -> Vec<u8> {
+ let header = array_header(values.len());
+ let mut data = vec![0; round_to_word(header + values.len() * 4)];
+ data[..4].copy_from_slice(&(values.len() as i32).to_le_bytes());
+ for (i, value) in values.iter().enumerate() {
+ data[header + i * 4..header + i * 4 +
4].copy_from_slice(&value.to_le_bytes());
+ }
+ data
+}
+
+fn serialize_row_array(rows: &[Vec<u8>]) -> Vec<u8> {
+ let header = array_header(rows.len());
+ let mut data = vec![0; round_to_word(header + rows.len() * 8)];
+ data[..4].copy_from_slice(&(rows.len() as i32).to_le_bytes());
+ for (i, row) in rows.iter().enumerate() {
+ let offset = data.len();
+ data.extend_from_slice(row);
+ data.resize(data.len() + round_to_word(row.len()) - row.len(), 0);
+ let slot = ((offset as u64) << 32) | row.len() as u64;
+ data[header + i * 8..header + i * 8 +
8].copy_from_slice(&slot.to_le_bytes());
+ }
+ data
+}
+
+fn row_from_data(data: &[u8], arity: i32) -> crate::Result<BinaryRow> {
+ if data.len() < BinaryRow::cal_fix_part_size_in_bytes(arity) as usize {
+ return Err(invalid(format!(
+ "IndexFileMeta nested row is too short for arity {arity}"
+ )));
+ }
+ Ok(BinaryRow::from_bytes(arity, data.to_vec()))
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeletionVectorMeta {
pub offset: i32,
@@ -94,6 +145,133 @@ pub struct IndexFileMeta {
pub global_index_meta: Option<GlobalIndexMeta>,
}
+impl IndexFileMeta {
+ /// Java `IndexFileMetaSerializer` row body, without its i32 length prefix.
+ pub fn to_serialized_row_data(&self) -> crate::Result<Vec<u8>> {
+ let mut row = BinaryRowBuilder::new(7);
+ row.write_bytes(0, self.index_type.as_bytes());
+ row.write_bytes(1, self.file_name.as_bytes());
+ row.write_long(2, self.file_size);
+ row.write_long(3, self.row_count);
+ if let Some(ranges) = &self.deletion_vectors_ranges {
+ let rows = ranges
+ .iter()
+ .map(|(name, range)| {
+ let mut dv = BinaryRowBuilder::new(4);
+ dv.write_bytes(0, name.as_bytes());
+ dv.write_int(1, range.offset);
+ dv.write_int(2, range.length);
+ match range.cardinality {
+ Some(value) => dv.write_long(3, value),
+ None => dv.set_null_at(3),
+ }
+ dv.build_row_data()
+ })
+ .collect::<Vec<_>>();
+ row.write_bytes(4, &serialize_row_array(&rows));
+ } else {
+ row.set_null_at(4);
+ }
+ match &self.external_path {
+ Some(value) => row.write_bytes(5, value.as_bytes()),
+ None => row.set_null_at(5),
+ }
+ if let Some(meta) = &self.global_index_meta {
+ let mut global = BinaryRowBuilder::new(6);
+ global.write_long(0, meta.row_range_start);
+ global.write_long(1, meta.row_range_end);
+ global.write_int(2, meta.index_field_id);
+ match &meta.extra_field_ids {
+ Some(values) => global.write_bytes(3,
&serialize_int_array(values)),
+ None => global.set_null_at(3),
+ }
+ match &meta.index_meta {
+ Some(value) => global.write_bytes(4, value),
+ None => global.set_null_at(4),
+ }
+ match &meta.source_meta {
+ Some(value) => global.write_bytes(5, value),
+ None => global.set_null_at(5),
+ }
+ row.write_bytes(6, &global.build_row_data());
+ } else {
+ row.set_null_at(6);
+ }
+ Ok(row.build_row_data())
+ }
+
+ /// Reverse of `to_serialized_row_data` for the current Java row schema.
+ pub fn from_serialized_row_data(data: &[u8]) -> crate::Result<Self> {
+ let row = row_from_data(data, 7)?;
+ let name = |pos| -> crate::Result<String> {
+ String::from_utf8(row.get_binary(pos)?.to_vec())
+ .map_err(|_| invalid(format!("IndexFileMeta field {pos} is not
UTF-8")))
+ };
+ let ranges = if row.is_null_at(4) {
+ None
+ } else {
+ let mut ranges = IndexMap::new();
+ for raw in deserialize_binary_array_rows(row.get_binary(4)?)? {
+ let dv = row_from_data(raw, 4)?;
+ let name = String::from_utf8(dv.get_binary(0)?.to_vec())
+ .map_err(|_| invalid("deletion vector file name is not
UTF-8"))?;
+ ranges.insert(
+ name,
+ DeletionVectorMeta {
+ offset: dv.get_int(1)?,
+ length: dv.get_int(2)?,
+ cardinality: if dv.is_null_at(3) {
+ None
+ } else {
+ Some(dv.get_long(3)?)
+ },
+ },
+ );
+ }
+ Some(ranges)
+ };
+ let external_path = if row.is_null_at(5) {
+ None
+ } else {
+ Some(name(5)?)
+ };
+ let global_index_meta = if row.is_null_at(6) {
+ None
+ } else {
+ let global = row_from_data(row.get_binary(6)?, 6)?;
+ Some(GlobalIndexMeta {
+ row_range_start: global.get_long(0)?,
+ row_range_end: global.get_long(1)?,
+ index_field_id: global.get_int(2)?,
+ extra_field_ids: if global.is_null_at(3) {
+ None
+ } else {
+ Some(deserialize_binary_array_int(global.get_binary(3)?)?)
+ },
+ index_meta: if global.is_null_at(4) {
+ None
+ } else {
+ Some(global.get_binary(4)?.to_vec())
+ },
+ source_meta: if global.is_null_at(5) {
+ None
+ } else {
+ Some(global.get_binary(5)?.to_vec())
+ },
+ })
+ };
+ Ok(Self {
+ index_type: name(0)?,
+ file_name: name(1)?,
+ file_size: row.get_long(2)?,
+ row_count: row.get_long(3)?,
+ deletion_vectors_ranges: ranges,
+ external_path,
+ global_index_meta,
+ })
+ }
+}
+
impl Display for IndexFileMeta {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
@@ -168,3 +346,42 @@ mod map_serde {
}
}
}
+
+#[cfg(test)]
+mod wire_tests {
+ use super::*;
+
+ #[test]
+ fn current_java_row_round_trip_with_nested_metadata() {
+ let mut ranges = IndexMap::new();
+ ranges.insert(
+ "data.parquet".into(),
+ DeletionVectorMeta {
+ offset: 3,
+ length: 12,
+ cardinality: Some(2),
+ },
+ );
+ let original = IndexFileMeta {
+ index_type: "DV".into(),
+ file_name: "index".into(),
+ file_size: 9,
+ row_count: 2,
+ deletion_vectors_ranges: Some(ranges),
+ external_path: Some("file:/index".into()),
+ global_index_meta: Some(GlobalIndexMeta {
+ row_range_start: 10,
+ row_range_end: 20,
+ index_field_id: 3,
+ extra_field_ids: Some(vec![1, 4]),
+ index_meta: Some(vec![1, 2]),
+ source_meta: Some(vec![3]),
+ }),
+ };
+ let bytes = original.to_serialized_row_data().unwrap();
+ assert_eq!(
+ IndexFileMeta::from_serialized_row_data(&bytes).unwrap(),
+ original
+ );
+ }
+}
diff --git a/crates/paimon/src/table/commit_message.rs
b/crates/paimon/src/table/commit_message.rs
index 55afbf64..f4e86ba0 100644
--- a/crates/paimon/src/table/commit_message.rs
+++ b/crates/paimon/src/table/commit_message.rs
@@ -15,8 +15,56 @@
// specific language governing permissions and limitations
// under the License.
-use crate::spec::DataFileMeta;
-use crate::spec::IndexFileMeta;
+use super::source::{read_i32, read_i64, take};
+use crate::spec::{BinaryRow, DataFileMeta, DataFileMetaRowLayout,
IndexFileMeta};
+
+/// Current Java `CommitMessageSerializer` body version. The version is carried
+/// by an enclosing serializer, not embedded in the body.
+pub const COMMIT_MESSAGE_SERIALIZER_VERSION: i32 = 14;
+
+fn invalid(message: impl Into<String>) -> crate::Error {
+ crate::Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+fn write_rows<T>(
+ out: &mut Vec<u8>,
+ values: &[T],
+ encode: impl Fn(&T) -> crate::Result<Vec<u8>>,
+) -> crate::Result<()> {
+ out.extend_from_slice(&(values.len() as i32).to_be_bytes());
+ for value in values {
+ let row = encode(value)?;
+ out.extend_from_slice(&(row.len() as i32).to_be_bytes());
+ out.extend_from_slice(&row);
+ }
+ Ok(())
+}
+
+fn read_rows<T>(
+ cur: &mut &[u8],
+ decode: impl Fn(&[u8]) -> crate::Result<T>,
+) -> crate::Result<Vec<T>> {
+ let count = read_i32(cur)?;
+ if count < 0 || count as usize > cur.len() / 4 {
+ return Err(invalid(format!(
+ "invalid CommitMessage list count: {count}"
+ )));
+ }
+ let mut values = Vec::new();
+ for _ in 0..count {
+ let length = read_i32(cur)?;
+ if length < 0 {
+ return Err(invalid(format!(
+ "negative CommitMessage row length: {length}"
+ )));
+ }
+ values.push(decode(take(cur, length as usize)?)?);
+ }
+ Ok(values)
+}
/// A commit message representing new files to be committed for a specific
partition and bucket.
///
@@ -41,6 +89,13 @@ pub struct CommitMessage {
pub deleted_index_files: Vec<IndexFileMeta>,
/// Files to be deleted (copy-on-write rewrite: old files replaced by
new_files).
pub deleted_files: Vec<DataFileMeta>,
+ /// Files removed by compaction (Java's separate compact increment).
+ pub compact_before: Vec<DataFileMeta>,
+ /// Files produced by compaction.
+ pub compact_after: Vec<DataFileMeta>,
+ pub compact_changelog_files: Vec<DataFileMeta>,
+ pub compact_new_index_files: Vec<IndexFileMeta>,
+ pub compact_deleted_index_files: Vec<IndexFileMeta>,
fixed_bucket_overwrite: bool,
}
@@ -56,15 +111,212 @@ impl CommitMessage {
new_index_files: Vec::new(),
deleted_index_files: Vec::new(),
deleted_files: Vec::new(),
+ compact_before: Vec::new(),
+ compact_after: Vec::new(),
+ compact_changelog_files: Vec::new(),
+ compact_new_index_files: Vec::new(),
+ compact_deleted_index_files: Vec::new(),
fixed_bucket_overwrite: false,
}
}
- pub(crate) fn mark_fixed_bucket_overwrite(&mut self) {
+ /// Supply the overwrite operation when restoring a message from Java's
wire
+ /// format, which does not encode this flag. Use the target committer's
mode.
+ pub fn mark_fixed_bucket_overwrite(&mut self) {
self.fixed_bucket_overwrite = true;
}
pub(crate) fn is_fixed_bucket_overwrite(&self) -> bool {
self.fixed_bucket_overwrite
}
+
+ /// Write the unframed Java v14 `CommitMessageSerializer.serialize` body.
+ pub fn serialize(&self) -> crate::Result<Vec<u8>> {
+ let mut out = Vec::new();
+ // The partition normally is SerializationUtils.serializeBinaryRow:
+ // i32 arity followed by a raw BinaryRow. Internal unpartitioned
writers
+ // may still use an empty Vec, which represents BinaryRow.EMPTY_ROW.
+ let partition = if self.partition.is_empty() {
+ BinaryRow::new(0).to_serialized_bytes()
+ } else {
+ BinaryRow::from_serialized_bytes(&self.partition)?;
+ self.partition.clone()
+ };
+ out.extend_from_slice(&(partition.len() as i32).to_be_bytes());
+ out.extend_from_slice(&partition);
+ out.extend_from_slice(&self.bucket.to_be_bytes());
+ out.push(u8::from(self.total_buckets.is_some()));
+ if let Some(value) = self.total_buckets {
+ out.extend_from_slice(&value.to_be_bytes());
+ }
+ for files in [
+ &self.new_files,
+ &self.deleted_files,
+ &self.new_changelog_files,
+ ] {
+ write_rows(&mut out, files, DataFileMeta::to_serialized_row_data)?;
+ }
+ for files in [&self.new_index_files, &self.deleted_index_files] {
+ write_rows(&mut out, files,
IndexFileMeta::to_serialized_row_data)?;
+ }
+ for files in [
+ &self.compact_before,
+ &self.compact_after,
+ &self.compact_changelog_files,
+ ] {
+ write_rows(&mut out, files, DataFileMeta::to_serialized_row_data)?;
+ }
+ for files in [
+ &self.compact_new_index_files,
+ &self.compact_deleted_index_files,
+ ] {
+ write_rows(&mut out, files,
IndexFileMeta::to_serialized_row_data)?;
+ }
+ out.push(u8::from(self.check_from_snapshot.is_some()));
+ if let Some(value) = self.check_from_snapshot {
+ out.extend_from_slice(&value.to_be_bytes());
+ }
+ Ok(out)
+ }
+
+ /// Decode an unframed Java `CommitMessageSerializer` v14 body.
+ pub fn deserialize(version: i32, bytes: &[u8]) -> crate::Result<Self> {
+ if version != COMMIT_MESSAGE_SERIALIZER_VERSION {
+ return Err(crate::Error::Unsupported {
+ message: format!("CommitMessage serializer version {version}
is not supported"),
+ });
+ }
+ let mut cur = bytes;
+ let partition_len = read_i32(&mut cur)?;
+ if partition_len < 0 {
+ return Err(invalid("negative CommitMessage partition length"));
+ }
+ let partition = take(&mut cur, partition_len as usize)?.to_vec();
+ BinaryRow::from_serialized_bytes(&partition)?;
+ let bucket = read_i32(&mut cur)?;
+ let total_buckets = match take(&mut cur, 1)?[0] {
+ 0 => None,
+ 1 => Some(read_i32(&mut cur)?),
+ value => return Err(invalid(format!("invalid totalBuckets flag:
{value}"))),
+ };
+ let data_file = |raw: &[u8]| {
+ DataFileMeta::from_serialized_row_data(raw,
DataFileMetaRowLayout::CURRENT)
+ };
+ let new_files = read_rows(&mut cur, data_file)?;
+ let deleted_files = read_rows(&mut cur, data_file)?;
+ let new_changelog_files = read_rows(&mut cur, data_file)?;
+ let new_index_files = read_rows(&mut cur,
IndexFileMeta::from_serialized_row_data)?;
+ let deleted_index_files = read_rows(&mut cur,
IndexFileMeta::from_serialized_row_data)?;
+ let compact_before = read_rows(&mut cur, data_file)?;
+ let compact_after = read_rows(&mut cur, data_file)?;
+ let compact_changelog_files = read_rows(&mut cur, data_file)?;
+ let compact_new_index_files = read_rows(&mut cur,
IndexFileMeta::from_serialized_row_data)?;
+ let compact_deleted_index_files =
+ read_rows(&mut cur, IndexFileMeta::from_serialized_row_data)?;
+ let check_from_snapshot = match take(&mut cur, 1)?[0] {
+ 0 => None,
+ 1 => Some(read_i64(&mut cur)?),
+ value => return Err(invalid(format!("invalid checkFromSnapshot
flag: {value}"))),
+ };
+ if !cur.is_empty() {
+ return Err(invalid(format!(
+ "{} trailing bytes after CommitMessage",
+ cur.len()
+ )));
+ }
+ Ok(Self {
+ partition,
+ bucket,
+ total_buckets,
+ new_files,
+ deleted_files,
+ check_from_snapshot,
+ new_changelog_files,
+ new_index_files,
+ deleted_index_files,
+ compact_before,
+ compact_after,
+ compact_changelog_files,
+ compact_new_index_files,
+ compact_deleted_index_files,
+ fixed_bucket_overwrite: false,
+ })
+ }
+
+ /// Import a message for the fixed-bucket overwrite commit path. Java's
+ /// wire format does not encode the commit operation, so the caller must
+ /// supply that context when restoring a prepared overwrite message.
+ pub fn deserialize_for_fixed_bucket_overwrite(
+ version: i32,
+ bytes: &[u8],
+ ) -> crate::Result<Self> {
+ let mut message = Self::deserialize(version, bytes)?;
+ message.mark_fixed_bucket_overwrite();
+ Ok(message)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use base64::Engine;
+
+ // Produced by Java CommitMessageSerializer v14 with BinaryRow.EMPTY_ROW,
+ // bucket 3, checkFromSnapshot 7. The second message also has totalBuckets
+ // 5 and one data-increment IndexFileMeta("I", "index", 9, 2).
+ const EMPTY: &str =
"AAAADAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABw==";
+ const INDEXED: &str =
"AAAADAAAAAAAAAAAAAAAAAAAAAMBAAAABQAAAAAAAAAAAAAAAAAAAAEAAABAAHAAAAAAAABJAAAAAAAAgWluZGV4AACFCQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAABw==";
+ const RICH_INDEXED: &str =
"AAAADAAAAAAAAAAAAAAAAAAAAAMBAAAABQAAAAAAAAAAAAAAAAAAAAEAAADgAAAAAAAAAABEVgAAAAAAgmluZGV4AACFCQAAAAAAAAACAAAAAAAAAEgAAABAAAAACwAAAIgAAABIAAAAmAAAAAEAAAAAAAAAOAAAABAAAAAAAAAAAAAAAAwAAAAoAAAAAwAAAAAAAAAMAAAAAAAAAAIAAAAAAAAAZGF0YS5wYXJxdWV0AAAAAGZpbGU6L2luZGV4AAAAAAAAAAAAAAAAAAoAAAAAAAAAFAAAAAAAAAADAAAAAAAAABAAAAA4AAAAAQIAAAAAAIIDAAAAAAAAgQIAAAAAAAAAAQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAc=";
+
+ #[test]
+ fn java_v14_golden_round_trip() {
+ for golden in [EMPTY, INDEXED, RICH_INDEXED] {
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(golden)
+ .unwrap();
+ let message =
+ CommitMessage::deserialize(COMMIT_MESSAGE_SERIALIZER_VERSION,
&bytes).unwrap();
+ assert_eq!(message.bucket, 3);
+ assert_eq!(message.check_from_snapshot, Some(7));
+ assert_eq!(message.serialize().unwrap(), bytes);
+ }
+ let indexed = base64::engine::general_purpose::STANDARD
+ .decode(INDEXED)
+ .unwrap();
+ let message = CommitMessage::deserialize(14, &indexed).unwrap();
+ assert_eq!(message.total_buckets, Some(5));
+ assert_eq!(message.new_index_files[0].file_name, "index");
+ let overwrite =
+ CommitMessage::deserialize_for_fixed_bucket_overwrite(14,
&indexed).unwrap();
+ assert!(overwrite.is_fixed_bucket_overwrite());
+ let mut empty_partition = CommitMessage::new(Vec::new(), 3,
Vec::new());
+ empty_partition.check_from_snapshot = Some(7);
+ let empty = base64::engine::general_purpose::STANDARD
+ .decode(EMPTY)
+ .unwrap();
+ assert_eq!(empty_partition.serialize().unwrap(), empty);
+ let rich = base64::engine::general_purpose::STANDARD
+ .decode(RICH_INDEXED)
+ .unwrap();
+ let message = CommitMessage::deserialize(14, &rich).unwrap();
+ let index = &message.new_index_files[0];
+ assert_eq!(index.external_path.as_deref(), Some("file:/index"));
+ assert_eq!(index.deletion_vectors_ranges.as_ref().unwrap().len(), 1);
+ assert_eq!(
+ index.global_index_meta.as_ref().unwrap().extra_field_ids,
+ Some(vec![1, 4])
+ );
+ }
+
+ #[test]
+ fn rejects_truncated_or_unsupported_message() {
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(EMPTY)
+ .unwrap();
+ assert!(CommitMessage::deserialize(13, &bytes).is_err());
+ assert!(CommitMessage::deserialize(14, &bytes[..bytes.len() -
1]).is_err());
+ let mut trailing = bytes;
+ trailing.push(0);
+ assert!(CommitMessage::deserialize(14, &trailing).is_err());
+ }
}
diff --git a/crates/paimon/src/table/data_file_writer.rs
b/crates/paimon/src/table/data_file_writer.rs
index e68ebc9a..2ace11e2 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -67,7 +67,7 @@ pub(crate) struct DataFileWriter {
current_row_count: i64,
index_options: Option<Arc<FileIndexOptions>>,
current_index: Option<DataFileIndexWriter>,
- /// Paths owned by this indexed write until prepare_commit hands them to
the caller.
+ /// Paths owned by this write until prepare_commit hands them to the
caller.
created_paths: Vec<String>,
}
@@ -177,8 +177,8 @@ impl DataFileWriter {
self.file_io.mkdirs(&format!("{bucket_dir}/")).await?;
let file_path = format!("{bucket_dir}/{file_name}");
+ self.created_paths.push(file_path.clone());
if self.index_options.is_some() {
- self.created_paths.push(file_path.clone());
self.created_paths.push(format!(
"{bucket_dir}/{}",
data_file_to_file_index_file_name(&file_name)
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 4f3ee315..b9fa2714 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -304,6 +304,16 @@ impl AppendDedicatedFormatFileWriter {
Ok(())
}
+ pub(crate) async fn abort(&mut self) {
+ self.normal_writer.abort().await;
+ for writer in &mut self.blob_writers {
+ writer.writer.abort().await;
+ }
+ if let Some(writer) = &mut self.vector_writer {
+ writer.writer.abort().await;
+ }
+ }
+
pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>>
{
let mut results = self.normal_writer.prepare_commit().await?;
diff --git a/crates/paimon/src/table/kv_file_writer.rs
b/crates/paimon/src/table/kv_file_writer.rs
index a4ca6830..24f3e3af 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -890,6 +890,25 @@ impl KeyValueFileWriter {
Ok(result)
}
+ pub(crate) async fn abort(&mut self) {
+ self.buffer.clear();
+ self.buffer_bytes = 0;
+ let bucket_path = bucket_path_under(
+ &self.config.table_location,
+ &self.config.partition_path,
+ self.config.bucket,
+ );
+ for file in self
+ .written_files
+ .drain(..)
+ .chain(self.written_changelog_files.drain(..))
+ {
+ for path in file.collect_files(&bucket_path) {
+ let _ = self.file_io.delete_file(&path).await;
+ }
+ }
+ }
+
/// Flush remaining buffer and return all written file metadata.
pub(crate) async fn prepare_commit(&mut self) -> Result<PreparedFiles> {
self.flush().await?;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 5f8923c1..c7363f3d 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -132,7 +132,7 @@ pub use audit_log_table::AuditLogTable;
pub use batch_vector_search_builder::BatchVectorSearchBuilder;
pub use blob_resolver::{BlobReader, BlobStream};
pub use branch_manager::BranchManager;
-pub use commit_message::CommitMessage;
+pub use commit_message::{CommitMessage, COMMIT_MESSAGE_SERIALIZER_VERSION};
pub use consumer_manager::ConsumerManager;
pub use cow_writer::{CopyOnWriteMergeWriter, FileInfo};
pub use data_evolution_writer::{DataEvolutionDeleteWriter,
DataEvolutionWriter};
diff --git a/crates/paimon/src/table/postpone_file_writer.rs
b/crates/paimon/src/table/postpone_file_writer.rs
index e651b3ed..1e3b64a4 100644
--- a/crates/paimon/src/table/postpone_file_writer.rs
+++ b/crates/paimon/src/table/postpone_file_writer.rs
@@ -67,6 +67,7 @@ pub(crate) struct PostponeFileWriter {
/// Timestamp captured when the current file was opened (used for
deterministic replay order).
current_file_creation_time: DateTime<Utc>,
written_files: Vec<DataFileMeta>,
+ created_paths: Vec<String>,
/// Background file close tasks spawned during rolling.
in_flight_closes: JoinSet<Result<DataFileMeta>>,
}
@@ -83,6 +84,7 @@ impl PostponeFileWriter {
current_file_start_seq: 0,
current_file_creation_time: Utc::now(),
written_files: Vec::new(),
+ created_paths: Vec::new(),
in_flight_closes: JoinSet::new(),
}
}
@@ -155,6 +157,18 @@ impl PostponeFileWriter {
Ok(())
}
+ pub(crate) async fn abort(&mut self) {
+ if let Some(writer) = self.current_writer.take() {
+ let _ = writer.close().await;
+ }
+ self.current_file_name = None;
+ while self.in_flight_closes.join_next().await.is_some() {}
+ for path in self.created_paths.drain(..) {
+ let _ = self.file_io.delete_file(&path).await;
+ }
+ self.written_files.clear();
+ }
+
pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>>
{
self.close_current_file().await?;
while let Some(result) = self.in_flight_closes.join_next().await {
@@ -164,6 +178,7 @@ impl PostponeFileWriter {
})??;
self.written_files.push(meta);
}
+ self.created_paths.clear();
Ok(std::mem::take(&mut self.written_files))
}
@@ -213,6 +228,7 @@ impl PostponeFileWriter {
self.file_io.mkdirs(&format!("{bucket_dir}/")).await?;
let physical_schema = build_physical_schema(&user_schema);
let file_path = format!("{bucket_dir}/{file_name}");
+ self.created_paths.push(file_path.clone());
let output = self.file_io.new_output(&file_path)?;
let writer = create_format_writer(
&output,
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 8ce382f0..b0b8ec2c 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -156,6 +156,22 @@ fn validate_fixed_bucket_commit_mode(messages:
&[CommitMessage], overwrite: bool
Ok(())
}
+fn reject_compact_increment(messages: &[CommitMessage]) -> Result<()> {
+ // Java writes this increment in a separate COMPACT snapshot.
+ if messages.iter().any(|message| {
+ !message.compact_before.is_empty()
+ || !message.compact_after.is_empty()
+ || !message.compact_changelog_files.is_empty()
+ || !message.compact_new_index_files.is_empty()
+ || !message.compact_deleted_index_files.is_empty()
+ }) {
+ return Err(crate::Error::Unsupported {
+ message: "Committing a compact increment requires a separate
COMPACT snapshot.".into(),
+ });
+ }
+ Ok(())
+}
+
/// Table commit logic for Paimon write operations.
///
/// Provides atomic commit functionality including append, overwrite and
truncate
@@ -164,6 +180,7 @@ pub struct TableCommit {
snapshot_manager: SnapshotManager,
snapshot_commit: Arc<dyn SnapshotCommit>,
commit_user: String,
+ ignore_empty_commit: bool,
total_buckets: i32,
// commit config
commit_max_retries: u32,
@@ -205,6 +222,7 @@ impl TableCommit {
snapshot_manager,
snapshot_commit,
commit_user,
+ ignore_empty_commit: true,
total_buckets,
commit_max_retries,
commit_timeout_ms,
@@ -219,6 +237,79 @@ impl TableCommit {
}
}
+ /// Control empty APPEND snapshots. Java stream commits set this to false.
+ pub fn with_ignore_empty_commit(mut self, ignore_empty_commit: bool) ->
Self {
+ self.ignore_empty_commit = ignore_empty_commit;
+ self
+ }
+
+ /// Java StreamTableCommit.filterAndCommit: sort identifiers and return the
+ /// number of groups remaining after filtering against committed snapshots.
+ pub async fn filter_and_commit(
+ &self,
+ mut commits: Vec<(i64, Vec<CommitMessage>)>,
+ ) -> Result<usize> {
+
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
+ self.table.ensure_not_branch_reference_for_write()?;
+ commits.sort_by_key(|(id, _)| *id);
+ let latest = self.snapshot_manager.get_latest_snapshot().await?;
+ let mut pending = Vec::new();
+ for (id, messages) in commits {
+ if !self.is_committed_identifier(&latest, id).await? {
+ pending.push((id, messages));
+ }
+ }
+ // Java checks every pending checkpoint before publishing any snapshot.
+ // Filtered checkpoints may reference files already expired by
retention.
+ for (_, messages) in &pending {
+ self.check_recovery_files(messages).await?;
+ }
+ let count = pending.len();
+ for (id, messages) in pending {
+ self.commit_with_identifier(messages, id).await?;
+ }
+ Ok(count)
+ }
+
+ async fn check_recovery_files(&self, messages: &[CommitMessage]) ->
Result<()> {
+ let index_in_bucket =
+
CoreOptions::new(self.table.schema().options()).index_file_in_data_file_dir();
+ for message in messages {
+ let bucket_path = self.bucket_path(&message.partition,
message.bucket)?;
+ let mut paths = Vec::new();
+ for file in message
+ .new_files
+ .iter()
+ .chain(&message.new_changelog_files)
+ .chain(&message.compact_after)
+ .chain(&message.compact_changelog_files)
+ {
+ paths.extend(file.collect_files(&bucket_path));
+ }
+ for file in message
+ .new_index_files
+ .iter()
+ .chain(&message.compact_new_index_files)
+ {
+ paths.push(committed_index_file_path(
+ self.table.location().trim_end_matches('/'),
+ &bucket_path,
+ index_in_bucket,
+ file,
+ ));
+ }
+ for path in paths {
+ if !self.table.file_io().exists(&path).await? {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot recover commit: file '{path}'
does not exist"),
+ source: None,
+ });
+ }
+ }
+ }
+ Ok(())
+ }
+
/// Commit new files in APPEND mode.
pub async fn commit(&self, commit_messages: Vec<CommitMessage>) ->
Result<()> {
self.commit_with_identifier(commit_messages, BATCH_COMMIT_IDENTIFIER)
@@ -264,17 +355,18 @@ impl TableCommit {
// A commit validates against the existing snapshot.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
+ reject_compact_increment(&commit_messages)?;
validate_fixed_bucket_commit_mode(&commit_messages, false)?;
validate_bucket_ownership(&commit_messages)?;
- if commit_messages.is_empty() {
+ if commit_messages.is_empty() && self.ignore_empty_commit {
return Ok(());
}
let entries = self.messages_to_entries(&commit_messages);
let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
let new_index_entries =
self.messages_to_index_entries(&commit_messages);
- let check_from_snapshot =
Self::min_check_from_snapshot(&commit_messages);
+ let check_from_snapshot = Self::check_from_snapshot(&commit_messages)?;
self.try_commit(
CommitEntriesPlan::Direct {
entries,
@@ -311,6 +403,7 @@ impl TableCommit {
// A commit validates against the existing snapshot.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
+ reject_compact_increment(&commit_messages)?;
validate_fixed_bucket_commit_mode(&commit_messages, false)?;
validate_bucket_ownership(&commit_messages)?;
@@ -321,7 +414,7 @@ impl TableCommit {
let entries = self.messages_to_entries(&commit_messages);
let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
let new_index_entries =
self.messages_to_index_entries(&commit_messages);
- let check_from_snapshot =
Self::min_check_from_snapshot(&commit_messages);
+ let check_from_snapshot = Self::check_from_snapshot(&commit_messages)?;
let result = self
.try_commit(
CommitEntriesPlan::Direct {
@@ -396,6 +489,7 @@ impl TableCommit {
// A commit validates against the existing snapshot.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
+ reject_compact_increment(&commit_messages)?;
validate_fixed_bucket_commit_mode(&commit_messages, true)?;
validate_bucket_ownership(&commit_messages)?;
@@ -426,7 +520,7 @@ impl TableCommit {
}
}
- let check_from_snapshot =
Self::min_check_from_snapshot(&commit_messages);
+ let check_from_snapshot = Self::check_from_snapshot(&commit_messages)?;
self.try_commit(
CommitEntriesPlan::Overwrite {
@@ -789,6 +883,8 @@ impl TableCommit {
.new_files
.iter()
.chain(message.new_changelog_files.iter())
+ .chain(message.compact_after.iter())
+ .chain(message.compact_changelog_files.iter())
{
for path in file.collect_files(&bucket_path) {
let _ = self.table.file_io().delete_file(&path).await;
@@ -805,7 +901,11 @@ impl TableCommit {
// through `indexFileFactory(partition, bucket)`. Deleting is
// best-effort, so a wrong path leaks the file silently instead of
// failing.
- for file in &message.new_index_files {
+ for file in message
+ .new_index_files
+ .iter()
+ .chain(&message.compact_new_index_files)
+ {
let path = committed_index_file_path(
table_path,
&bucket_path,
@@ -853,11 +953,11 @@ impl TableCommit {
let mut duplicate_check_start_snapshot_id: Option<i64> = None;
let mut retry_state: Option<Box<RetryState>> = None;
let start_time_ms = current_time_millis();
- // An identified destructive no-op must still record its identifier.
- // Otherwise a retry after an intervening write can execute the
operation
- // for the first time and delete data which was not present originally.
- let commit_empty_overwrite =
- filter_committed && plan.commit_kind_hint() ==
CommitKind::OVERWRITE;
+ // Java records static overwrite/truncate operations even when no files
+ // match. Dynamic empty overwrite exits before constructing this plan.
+ let commit_empty_overwrite = plan.commit_kind_hint() ==
CommitKind::OVERWRITE;
+ let commit_empty_append =
+ !self.ignore_empty_commit && matches!(plan,
CommitEntriesPlan::Direct { .. });
let mut filter_committed = filter_committed;
let mut publication_uncertain = false;
@@ -896,6 +996,7 @@ impl TableCommit {
&& resolved.changelog_entries.is_empty()
&& !resolved.index_manifest_changed
&& !commit_empty_overwrite
+ && !commit_empty_append
{
break;
}
@@ -3014,12 +3115,42 @@ impl TableCommit {
Ok(spec)
}
- /// Earliest source snapshot requested by row-id conflict checks.
- fn min_check_from_snapshot(messages: &[CommitMessage]) -> Option<i64> {
- messages
- .iter()
- .filter_map(|message| message.check_from_snapshot)
- .min()
+ /// Check conflicts from the earliest writer snapshot, validating row-id
baselines.
+ fn check_from_snapshot(messages: &[CommitMessage]) -> Result<Option<i64>> {
+ let mut check_from_snapshot: Option<i64> = None;
+ for message in messages {
+ let Some(snapshot) = message.check_from_snapshot else {
+ continue;
+ };
+ if snapshot < 0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Invalid row-id check snapshot:
{snapshot}"),
+ source: None,
+ });
+ }
+ check_from_snapshot =
+ Some(check_from_snapshot.map_or(snapshot, |previous|
previous.min(snapshot)));
+ }
+ if check_from_snapshot.is_some() {
+ for message in messages {
+ if message.check_from_snapshot.is_some() {
+ continue;
+ }
+ if message
+ .new_files
+ .iter()
+ .chain(&message.deleted_files)
+ .any(|file| file.first_row_id.is_some())
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "A row-id commit message is missing its
check-from snapshot."
+ .into(),
+ source: None,
+ });
+ }
+ }
+ }
+ Ok(check_from_snapshot)
}
/// Convert commit messages to manifest entries (ADD/DELETE kind).
@@ -3365,6 +3496,55 @@ mod tests {
use apache_avro::types::Value;
use chrono::{DateTime, Utc};
+ #[test]
+ fn check_from_snapshot_uses_minimum_baseline() {
+ let mut tagged = CommitMessage::new(Vec::new(), 0, Vec::new());
+ tagged.check_from_snapshot = Some(7);
+ assert_eq!(
+ TableCommit::check_from_snapshot(&[tagged.clone()]).unwrap(),
+ Some(7)
+ );
+ assert_eq!(TableCommit::check_from_snapshot(&[]).unwrap(), None);
+
+ let mut different = tagged.clone();
+ different.check_from_snapshot = Some(8);
+ let untagged = CommitMessage::new(Vec::new(), 0, Vec::new());
+ for messages in [
+ vec![tagged.clone(), different.clone(), untagged.clone()],
+ vec![different, untagged.clone(), tagged.clone()],
+ ] {
+ assert_eq!(
+ TableCommit::check_from_snapshot(&messages).unwrap(),
+ Some(7)
+ );
+ }
+ assert_eq!(TableCommit::check_from_snapshot(&[untagged]).unwrap(),
None);
+ }
+
+ #[test]
+ fn check_from_snapshot_rejects_invalid_or_missing_baselines() {
+ let mut tagged = CommitMessage::new(Vec::new(), 0, Vec::new());
+ tagged.check_from_snapshot = Some(7);
+ let mut negative = tagged.clone();
+ negative.check_from_snapshot = Some(-1);
+ assert!(TableCommit::check_from_snapshot(&[negative]).is_err());
+ let mut missing = CommitMessage::new(Vec::new(), 0,
vec![test_data_file("x", 1)]);
+ missing.new_files[0].first_row_id = Some(1);
+ assert!(TableCommit::check_from_snapshot(&[tagged, missing]).is_err());
+ }
+
+ #[test]
+ fn compact_increment_requires_separate_snapshot() {
+ let mut message = CommitMessage::new(Vec::new(), 0, Vec::new());
+ message.compact_before.push(test_data_file("before", 1));
+ message.compact_after.push(test_data_file("after", 1));
+ message
+ .compact_changelog_files
+ .push(test_data_file("changelog", 1));
+ assert!(reject_compact_increment(&[message]).is_err());
+ assert!(reject_compact_increment(&[CommitMessage::new(Vec::new(), 0,
Vec::new())]).is_ok());
+ }
+
#[tokio::test]
async fn test_query_auth_table_refuses_commit_paths() {
let table = crate::table::query_auth_table();
@@ -5178,7 +5358,7 @@ mod tests {
}
#[tokio::test]
- async fn test_truncate_missing_partition_is_noop() {
+ async fn test_truncate_missing_partition_records_java_overwrite_snapshot()
{
let file_io = test_file_io();
let table_path = "memory:/test_truncate_missing_partition";
setup_dirs(&file_io, table_path).await;
@@ -5203,7 +5383,9 @@ mod tests {
let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
- assert_eq!(snapshot.id(), 1);
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.commit_kind(), &CommitKind::OVERWRITE);
+ assert_eq!(snapshot.delta_record_count(), Some(0));
assert_eq!(snapshot.total_record_count(), Some(100));
}
@@ -5465,10 +5647,15 @@ mod tests {
second_partial.first_row_id = Some(0);
second_partial.file_source = Some(0);
second_partial.write_cols = Some(vec!["name".to_string()]);
- let mut second_message = CommitMessage::new(partition, 0,
vec![second_partial]);
+ let mut second_message = CommitMessage::new(partition.clone(), 0,
vec![second_partial]);
second_message.check_from_snapshot = Some(1);
+ // A newer writer in the same commit must not hide the stale update.
+ let mut fresh_message =
+ CommitMessage::new(partition, 0,
vec![test_data_file("fresh.parquet", 1)]);
+ fresh_message.check_from_snapshot = Some(2);
+ fresh_message.new_files[0].file_source = Some(0);
- let result = commit.commit(vec![second_message]).await;
+ let result = commit.commit(vec![fresh_message, second_message]).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
@@ -5476,6 +5663,7 @@ mod tests {
err_msg.contains("multiple MERGE INTO operations have encountered
conflicts"),
"expected row-id/column conflict, got: {err_msg}"
);
+ assert_eq!(latest_snapshot(&file_io, table_path).await.unwrap().id(),
2);
}
#[tokio::test]
@@ -5512,14 +5700,23 @@ mod tests {
id_partial.first_row_id = Some(0);
id_partial.file_source = Some(0);
id_partial.write_cols = Some(vec!["id".to_string()]);
- let mut id_message = CommitMessage::new(partition, 0,
vec![id_partial]);
+ let mut id_message = CommitMessage::new(partition.clone(), 0,
vec![id_partial]);
id_message.check_from_snapshot = Some(1);
+ let mut fresh_message =
+ CommitMessage::new(partition, 0,
vec![test_data_file("fresh.parquet", 1)]);
+ fresh_message.check_from_snapshot = Some(2);
+ fresh_message.new_files[0].file_source = Some(0);
- commit.commit(vec![id_message]).await.unwrap();
+ commit
+ .commit(vec![id_message, fresh_message])
+ .await
+ .unwrap();
let snap_manager = SnapshotManager::new(file_io.clone(),
table_path.to_string());
let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
assert_eq!(snapshot.id(), 3);
+ // Snapshot counts include the two partial-column files.
+ assert_eq!(snapshot.total_record_count(), Some(301));
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 4fc57a58..f23c83f3 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -880,6 +880,19 @@ impl TableWrite {
Ok(())
}
+ /// Close without preparing another commit, discarding only outstanding
output.
+ /// Files already returned by prepare_commit belong to the caller.
+ pub async fn close(&mut self) {
+ for (_, writer) in self.partition_writers.drain() {
+ match writer {
+ FileWriter::Append(mut writer) => writer.abort().await,
+ FileWriter::AppendDedicated(mut writer) =>
writer.abort().await,
+ FileWriter::KeyValue(mut writer) => writer.abort().await,
+ FileWriter::Postpone(mut writer) => writer.abort().await,
+ }
+ }
+ }
+
/// Close all writers and collect CommitMessages for use with TableCommit.
/// Writers are cleared after this call, allowing the TableWrite to be
reused.
pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
diff --git a/docs/src/python-binding.md b/docs/src/python-binding.md
index 318cfb7f..6e5ce569 100644
--- a/docs/src/python-binding.md
+++ b/docs/src/python-binding.md
@@ -237,7 +237,7 @@ batch = pa.record_batch(
)
# Create a write builder (shared commit_user for writer and committer)
-wb = table.new_write_builder()
+wb = table.new_batch_write_builder()
# Write batches
write = wb.new_write()
@@ -265,7 +265,7 @@ ctx.sql("INSERT INTO paimon.default.my_table VALUES (1,
'alice'), (2, 'bob')")
The input batch schema is strictly validated against the table schema:
field count, order, names, and types must match exactly. A `ValueError` is
raised on mismatch.
!!! note "Write Builder Consistency"
- The writer and committer must come from the same `WriteBuilder` — they
share a `commit_user` for duplicate-commit detection. Passing messages from one
builder's writer to another builder's committer will raise a `ValueError`.
+ The writer and committer must come from the same `BatchWriteBuilder` —
they share a `commit_user` for duplicate-commit detection. Passing messages
from one builder's writer to another builder's committer will raise a
`ValueError`.
## Column Projection
@@ -539,7 +539,7 @@ batch = pa.record_batch(
[pa.array([1, 2, 3], pa.int32()), pa.array(["alice", "bob", "carol"],
pa.string())],
names=["id", "name"],
)
-wb = table.new_write_builder()
+wb = table.new_batch_write_builder()
write = wb.new_write()
write.write_arrow(batch)
wb.new_commit().commit(write.prepare_commit())