blob: 2b6042967ce1a8e5e0a36bcaf91c6b3c1857780d [file]
// 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.
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_datafusion::runtime::runtime;
use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::prelude::*;
use crate::error::to_py_err;
/// 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
/// nullable flag is intentionally NOT compared, since `build_target_arrow_schema`
/// derives nullability from the Paimon field while pyarrow-constructed batches
/// infer nullable=true. No cast — callers supply correctly-typed batches.
///
/// Type matching is strict (no binary-family interchange): the lower write path
/// downcasts to the exact Arrow array for each Paimon type (e.g. a `Binary` /
/// `VarBinary` field requires `arrow_array::BinaryArray`, not `LargeBinary` /
/// `FixedSizeBinary`). Accepting a near-equivalent type here would pass
/// validation but then fail deeper with a type-mismatch (or write files whose
/// Arrow schema differs from the table), so it is rejected up front.
fn validate_batch_schema(input: &ArrowSchema, target: &ArrowSchema) -> PyResult<()> {
let mismatch = || {
PyValueError::new_err(format!(
"Input schema is not consistent with the table schema. \
input: {input:?}, table: {target:?}"
))
};
if input.fields().len() != target.fields().len() {
return Err(mismatch());
}
for (i, t) in input.fields().iter().zip(target.fields().iter()) {
if i.name() != t.name() {
return Err(mismatch());
}
if i.data_type() != t.data_type() {
return Err(mismatch());
}
}
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 {
table: Arc<Table>,
commit_user: String,
}
impl PyWriteBuilder {
pub 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> {
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,
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
.table
.new_write_builder()
.with_commit_user(self.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(),
})
}
}
/// 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.
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.
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 }))
.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 messages = py
.detach(|| rt.block_on(async { self.inner.prepare_commit().await }))
.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(),
})
.collect())
}
}
/// A committer that persists prepared commit messages as a snapshot.
#[pyclass(name = "TableCommit", module = "pypaimon_rust.datafusion")]
pub struct PyTableCommit {
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.
commit_user: String,
}
#[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 mut inner_messages = Vec::new();
let iter = messages.try_iter().map_err(|_| {
PyTypeError::new_err("commit() expects a sequence of CommitMessage objects")
})?;
for item in iter {
let item = item?;
let msg: PyRef<PyCommitMessage> = item.extract().map_err(|_| {
PyTypeError::new_err("commit() expects a sequence of CommitMessage objects")
})?;
if msg.table_location != self.table_location {
return Err(PyValueError::new_err(format!(
"commit message was prepared for a different table \
(message table '{}', committer table '{}')",
msg.table_location, self.table_location
)));
}
if msg.commit_user != self.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());
}
let rt = runtime();
py.detach(|| rt.block_on(async { self.inner.commit(inner_messages).await }))
.map_err(to_py_err)
}
}
/// 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`.
#[pyclass(name = "CommitMessage", module = "pypaimon_rust.datafusion")]
pub struct PyCommitMessage {
pub(crate) inner: CommitMessage,
pub(crate) table_location: String,
pub(crate) commit_user: String,
}