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 9f8338d9 Support Arrow row ID inputs for deletion (#1210)
9f8338d9 is described below
commit 9f8338d91a1c3d5a5ff07f0a7716f7f7f256f518
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 21:43:30 2026 +0800
Support Arrow row ID inputs for deletion (#1210)
---
bindings/python/README.md | 4 +
.../python/python/pypaimon_rust/datafusion.pyi | 11 +-
bindings/python/src/write.rs | 175 ++++++++++++++++++++-
bindings/python/tests/test_update.py | 145 +++++++++++++++++
crates/paimon/src/table/table_update.rs | 8 +-
crates/paimon/src/table/table_update_plan_tests.rs | 22 ++-
.../tests/data_evolution_row_sidecar_test.rs | 2 +-
7 files changed, 354 insertions(+), 13 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index d793d2d3..696e746a 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -227,6 +227,10 @@ authorization and the captured snapshot, and adds sorted,
deduplicated row range
for reading. It does not check row liveness or authorize file access; readers
and mutation workers retain those checks. The selected plan is only for
reading.
+Batch and stream updates accept integer Arrow arrays and chunked arrays through
+`delete_by_row_id`, as well as integer sequences. Arrow chunks and slices are
+imported directly; nulls and values outside Int64 are rejected before writing.
+
### Shard column updates
The batch and stream update objects expose
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 92964a99..0ede5a1f 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -638,7 +638,14 @@ class BatchTableUpdate:
"""
...
def with_update_type(self, update_cols: List[str]) -> "BatchTableUpdate":
...
- def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]:
...
+ def delete_by_row_id(
+ self, row_ids: Union[Sequence[int], pyarrow.Array,
pyarrow.ChunkedArray]
+ ) -> List[CommitMessage]:
+ """Prepare row deletes from integer IDs; Arrow inputs retain chunks
and slices.
+
+ Nulls and values outside Int64 are rejected. Duplicate IDs are deleted
once.
+ """
+ ...
def delete_by_predicate(
self, predicate: Optional[Dict[str, Any]]
) -> List[CommitMessage]:
@@ -712,7 +719,7 @@ class StreamTableUpdate:
...
def with_update_type(self, update_cols: List[str]) -> "StreamTableUpdate":
...
def delete_by_row_id(
- self, row_ids: Sequence[int], commit_identifier: int
+ self, row_ids: Union[Sequence[int], pyarrow.Array,
pyarrow.ChunkedArray], commit_identifier: int
) -> List[CommitMessage]: ...
def delete_by_predicate(
self, predicate: Optional[Dict[str, Any]], commit_identifier: int
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 7a8b0b91..0e1009c4 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -18,7 +18,9 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
-use arrow::datatypes::Schema as ArrowSchema;
+use arrow::array::{Array, Int64Array};
+use arrow::compute::{cast_with_options, CastOptions};
+use arrow::datatypes::{DataType as ArrowDataType, Schema as ArrowSchema};
use arrow::pyarrow::{FromPyArrow, ToPyArrow};
use arrow::record_batch::RecordBatch;
use paimon::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataType, Datum,
RowType};
@@ -28,7 +30,7 @@ use paimon::table::{
COMMIT_MESSAGE_SERIALIZER_VERSION,
};
use paimon_datafusion::runtime::runtime;
-use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
+use pyo3::exceptions::{PyModuleNotFoundError, PyRuntimeError, PyTypeError,
PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyBytes, PyDict, PyString};
use pyo3::{PyTraverseError, PyVisit};
@@ -41,6 +43,75 @@ use crate::predicate::{dict_to_table_predicate, py_to_datum};
type PartitionSpec = HashMap<String, Option<Datum>>;
type PythonPartitionSpec = HashMap<String, Py<PyAny>>;
+fn delete_row_id_arrays(row_ids: &Bound<'_, PyAny>) ->
PyResult<Vec<Int64Array>> {
+ let py = row_ids.py();
+ let pa = match py.import("pyarrow") {
+ Ok(pa) => pa,
+ // Plain sequence deletes do not require the optional PyArrow package.
+ // Preserve errors from a broken installation or its dependencies.
+ Err(error)
+ if error.is_instance_of::<PyModuleNotFoundError>(py)
+ && error
+ .value(py)
+ .getattr("name")?
+ .extract::<Option<String>>()?
+ .as_deref()
+ == Some("pyarrow") =>
+ {
+ return Ok(vec![Int64Array::from(row_ids.extract::<Vec<i64>>()?)]);
+ }
+ Err(error) => return Err(error),
+ };
+ let is_array = row_ids.is_instance(&pa.getattr("Array")?)?;
+ let is_chunked = row_ids.is_instance(&pa.getattr("ChunkedArray")?)?;
+ if !is_array && !is_chunked {
+ return Ok(vec![Int64Array::from(row_ids.extract::<Vec<i64>>()?)]);
+ }
+ // Check the declared type even when a ChunkedArray contains no chunks.
+ if
!ArrowDataType::from_pyarrow_bound(&row_ids.getattr("type")?)?.is_integer() {
+ return Err(PyTypeError::new_err(
+ "_ROW_ID input must be an integer Arrow array",
+ ));
+ }
+ let arrays = if is_array {
+ vec![crate::arrow_input::array_from_pyarrow(row_ids)?]
+ } else {
+ row_ids
+ .getattr("chunks")?
+ .try_iter()?
+ .map(|chunk| crate::arrow_input::array_from_pyarrow(&chunk?))
+ .collect::<PyResult<Vec<_>>>()?
+ };
+ py.detach(|| {
+ arrays
+ .into_iter()
+ .map(|array| {
+ if array.null_count() != 0 {
+ return Err(PyValueError::new_err("_ROW_ID must not be
null"));
+ }
+ let converted = cast_with_options(
+ array.as_ref(),
+ &ArrowDataType::Int64,
+ &CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )
+ .map_err(|error| {
+ PyValueError::new_err(format!(
+ "_ROW_ID cannot be represented as Int64: {error}"
+ ))
+ })?;
+ Ok(converted
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .expect("cast to Int64 returned an Int64Array")
+ .clone())
+ })
+ .collect()
+ })
+}
+
/// Shared implementation state; public builders keep batch and stream
contracts separate.
struct WriteContext {
table: Arc<Table>,
@@ -880,10 +951,19 @@ impl UpdateContext {
fn delete_by_row_id(
&self,
py: Python<'_>,
- row_ids: Vec<i64>,
+ row_ids: &Bound<'_, PyAny>,
) -> PyResult<Vec<PyCommitMessage>> {
+ let arrays = delete_row_id_arrays(row_ids)?;
let messages = py
- .detach(||
runtime().block_on(self.inner.delete_by_row_id(row_ids)))
+ .detach(|| {
+ runtime().block_on(
+ self.inner.delete_by_row_id(
+ arrays
+ .iter()
+ .flat_map(|array| array.values().iter().copied()),
+ ),
+ )
+ })
.map_err(to_py_err)?;
Ok(wrap_messages(
messages,
@@ -1193,7 +1273,7 @@ impl PyStreamTableUpdate {
fn delete_by_row_id(
&self,
py: Python<'_>,
- row_ids: Vec<i64>,
+ row_ids: &Bound<'_, PyAny>,
commit_identifier: i64,
) -> PyResult<Vec<PyCommitMessage>> {
// The stream committer applies the identifier to the returned
messages.
@@ -1327,7 +1407,7 @@ impl PyBatchTableUpdate {
fn delete_by_row_id(
&self,
py: Python<'_>,
- row_ids: Vec<i64>,
+ row_ids: &Bound<'_, PyAny>,
) -> PyResult<Vec<PyCommitMessage>> {
self.context.delete_by_row_id(py, row_ids)
}
@@ -1871,6 +1951,89 @@ mod tests {
locals
}
+ #[test]
+ fn row_id_delete_inputs_preserve_arrow_chunks_and_integer_values() {
+ Python::attach(|py| {
+ let locals = PyDict::new(py);
+ py.run(
+ c_str!(
+ r#"
+import pyarrow as pa
+valid = [
+ ([2, 1], [2, 1], [2]),
+ ((2, 1), [2, 1], [2]),
+ (pa.array([None, 2, 1, None], type=pa.int64()).slice(1, 2), [2, 1], [2]),
+ (pa.chunked_array([[999, 2], [1, 999]], type=pa.int16()).slice(1, 2), [2,
1], [1, 1]),
+ (pa.array([2 ** 63 - 1], type=pa.uint64()), [2 ** 63 - 1], [1]),
+ (pa.array([], type=pa.uint32()), [], [0]),
+ (pa.chunked_array([], type=pa.int64()), [], []),
+]
+invalid = [
+ (pa.array([None], type=pa.int64()), 'value'),
+ (pa.chunked_array([[0], [None]], type=pa.int64()), 'value'),
+ (pa.array([2 ** 63], type=pa.uint64()), 'value'),
+ (pa.chunked_array([[0], [2 ** 63]], type=pa.uint64()), 'value'),
+ (pa.array([1.5]), 'type'),
+ (pa.array([True]), 'type'),
+ (pa.array([], type=pa.bool_()), 'type'),
+ (pa.array([], type=pa.string()), 'type'),
+ (pa.chunked_array([], type=pa.float64()), 'type'),
+ ([1.5], 'type'),
+ ([None], 'type'),
+ ([2 ** 63], 'overflow'),
+]
+"#
+ ),
+ Some(&locals),
+ Some(&locals),
+ )
+ .unwrap();
+ for case in locals
+ .get_item("valid")
+ .unwrap()
+ .unwrap()
+ .try_iter()
+ .unwrap()
+ {
+ let case = case.unwrap();
+ let arrays =
delete_row_id_arrays(&case.get_item(0).unwrap()).unwrap();
+ let values: Vec<i64> = arrays
+ .iter()
+ .flat_map(|array| array.values().iter().copied())
+ .collect();
+ let lengths: Vec<usize> =
arrays.iter().map(Int64Array::len).collect();
+ assert_eq!(
+ values,
+ case.get_item(1).unwrap().extract::<Vec<i64>>().unwrap()
+ );
+ assert_eq!(
+ lengths,
+ case.get_item(2).unwrap().extract::<Vec<usize>>().unwrap()
+ );
+ }
+ for case in locals
+ .get_item("invalid")
+ .unwrap()
+ .unwrap()
+ .try_iter()
+ .unwrap()
+ {
+ let case = case.unwrap();
+ let error =
delete_row_id_arrays(&case.get_item(0).unwrap()).unwrap_err();
+ let kind: String =
case.get_item(1).unwrap().extract().unwrap();
+ assert!(
+ match kind.as_str() {
+ "type" => error.is_instance_of::<PyTypeError>(py),
+ "value" => error.is_instance_of::<PyValueError>(py),
+ "overflow" =>
error.is_instance_of::<pyo3::exceptions::PyOverflowError>(py),
+ _ => false,
+ },
+ "unexpected {kind} error: {error}"
+ );
+ }
+ });
+ }
+
#[test]
fn
captured_plan_rejects_other_operations_before_python_input_consumption() {
Python::attach(|py| {
diff --git a/bindings/python/tests/test_update.py
b/bindings/python/tests/test_update.py
index 83dc9464..84f8be27 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -24,6 +24,151 @@ from pypaimon_rust.datafusion import (
)
[email protected]('stream', [False, True])
+def test_arrow_row_id_deletes_preserve_captured_bucket_vectors(tmp_path,
stream):
+ context = SQLContext()
+ context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+ context.sql('CREATE SCHEMA paimon.arrow_deletes')
+ context.sql("""CREATE TABLE paimon.arrow_deletes.t (id INT) WITH (
+ 'bucket' = '-1', 'row-tracking.enabled' = 'true',
+ 'data-evolution.enabled' = 'true', 'deletion-vectors.enabled' =
'true')""")
+ for values in ['(10), (11)', '(20), (21)']:
+ context.sql('INSERT INTO paimon.arrow_deletes.t (id) VALUES ' + values)
+ table = PaimonCatalog({'warehouse':
str(tmp_path)}).get_table('arrow_deletes.t')
+ builder =
(table.new_stream_write_builder().with_commit_user('arrow-deletes')
+ if stream else table.new_batch_write_builder())
+
+ def delete(update, row_ids, identifier):
+ return (update.delete_by_row_id(row_ids, identifier) if stream
+ else update.delete_by_row_id(row_ids))
+
+ def commit(messages, identifier):
+ committer = builder.new_commit()
+ committer.commit(identifier, messages) if stream else
committer.commit(messages)
+
+ def rows():
+ return pa.Table.from_batches(context.sql(
+ 'SELECT id FROM paimon.arrow_deletes.t ORDER BY
id')).column('id').to_pylist()
+
+ # Keep the legacy sequence input and seed a DV for a different anchor.
+ commit(delete(builder.new_update(), [0], 40), 40)
+ plan = table.new_read_builder().new_scan().plan_all()
+ groups = datafusion.RowIdPlan(plan).groups()
+ selected = datafusion.Plan(plan.snapshot_id(), [split for first, _, split
in groups if first >= 2])
+ update = builder.new_update().with_plan(selected)
+ context.sql('INSERT INTO paimon.arrow_deletes.t (id) VALUES (30)')
+
+ ids = pa.chunked_array([
+ pa.array([999, 2, 2], type=pa.uint64()).slice(1),
+ pa.array([3, 999], type=pa.uint64()).slice(0, 1),
+ ])
+ messages = delete(update, ids, 41)
+ stale = delete(update, pa.array([3], type=pa.int16()), 42)
+ assert rows() == [11, 20, 21, 30]
+ commit(messages, 41)
+ assert rows() == [11, 30]
+ # A second preparation from the captured snapshot cannot replace its old
+ # index again after the first publication.
+ snapshot = table.latest_snapshot().id()
+ with pytest.raises(ValueError, match='missing deletion vector index'):
+ commit(stale, 42)
+ assert table.latest_snapshot().id() == snapshot
+ assert rows() == [11, 30]
+
+
[email protected]('stream', [False, True])
+def test_arrow_row_id_delete_input_validation(tmp_path, stream):
+ from pathlib import Path
+
+ context = SQLContext()
+ context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+ context.sql('CREATE SCHEMA paimon.delete_inputs')
+ context.sql("""CREATE TABLE paimon.delete_inputs.t (id INT) WITH (
+ 'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+ 'deletion-vectors.enabled' = 'true')""")
+ context.sql('INSERT INTO paimon.delete_inputs.t (id) VALUES (10)')
+ table = PaimonCatalog({'warehouse':
str(tmp_path)}).get_table('delete_inputs.t')
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ update =
builder.new_update().with_plan(table.new_read_builder().new_scan().plan_all())
+ options = {'commit_identifier': 7} if stream else {}
+
+ for empty in [[], pa.array([], type=pa.int8()), pa.chunked_array([],
type=pa.uint64())]:
+ assert update.delete_by_row_id(empty, **options) == []
+ for ids in [
+ (0,),
+ pa.array([0], type=pa.int8()),
+ pa.array([0], type=pa.uint32()),
+ pa.array([None, 0, None], type=pa.int64()).slice(1, 1),
+ pa.chunked_array([pa.array([], type=pa.uint64()), pa.array([0],
type=pa.uint64())]),
+ ]:
+ assert update.delete_by_row_id(ids, **options)
+
+ def index_files():
+ return {path for path in Path(table.location()).rglob('index-*') if
path.is_file()}
+
+ prepared_indexes = index_files()
+ assert prepared_indexes
+ for ids, error in [
+ (pa.array([None], type=pa.int64()), ValueError),
+ (pa.chunked_array([[0], [None]], type=pa.int64()), ValueError),
+ (pa.array([1.5], type=pa.float64()), TypeError),
+ (pa.chunked_array([], type=pa.float64()), TypeError),
+ (pa.array([], type=pa.bool_()), TypeError),
+ (pa.array([], type=pa.string()), TypeError),
+ (pa.array([2 ** 63], type=pa.uint64()), ValueError),
+ (pa.chunked_array([[0], [2 ** 63]], type=pa.uint64()), ValueError),
+ (pa.array([-1], type=pa.int64()), ValueError),
+ ([1.5], TypeError),
+ ([None], TypeError),
+ ([2 ** 63], OverflowError),
+ ]:
+ with pytest.raises(error):
+ update.delete_by_row_id(ids, **options)
+ assert index_files() == prepared_indexes
+ assert table.latest_snapshot().id() == 1
+
+
[email protected]('stream', [False, True])
+def test_sequence_row_id_deletes_do_not_require_pyarrow(tmp_path, stream,
monkeypatch):
+ import builtins
+
+ context = SQLContext()
+ context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+ context.sql('CREATE SCHEMA paimon.sequence_deletes')
+ context.sql("""CREATE TABLE paimon.sequence_deletes.t (id INT) WITH (
+ 'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true',
+ 'deletion-vectors.enabled' = 'true')""")
+ context.sql('INSERT INTO paimon.sequence_deletes.t (id) VALUES (10), (20),
(30)')
+
+ original_import = builtins.__import__
+
+ def without_pyarrow(name, *args, **kwargs):
+ if name == 'pyarrow' or name.startswith('pyarrow.'):
+ raise ModuleNotFoundError("No module named 'pyarrow'",
name='pyarrow')
+ return original_import(name, *args, **kwargs)
+
+ # PyArrow is optional for the SDK. Plain sequence deletes and commits
+ # must remain usable against existing tables without that dependency.
+ with monkeypatch.context() as patched:
+ patched.setattr(builtins, '__import__', without_pyarrow)
+ table = PaimonCatalog({'warehouse':
str(tmp_path)}).get_table('sequence_deletes.t')
+ builder =
(table.new_stream_write_builder().with_commit_user('sequence-deletes')
+ if stream else table.new_batch_write_builder())
+ for identifier, row_ids in enumerate([[], [0], (1,)]):
+ update = builder.new_update()
+ messages = (update.delete_by_row_id(row_ids, identifier) if stream
+ else update.delete_by_row_id(row_ids))
+ if not row_ids:
+ assert messages == []
+ continue
+ assert messages
+ commit = builder.new_commit()
+ commit.commit(identifier, messages) if stream else
commit.commit(messages)
+
+ assert pa.Table.from_batches(context.sql(
+ 'SELECT id FROM paimon.sequence_deletes.t')).column('id').to_pylist()
== [30]
+
+
def
test_upsert_batches_preserve_missing_fields_nulls_and_stream_updates(tmp_path):
context = SQLContext()
context.register_catalog('paimon', {'warehouse': str(tmp_path)})
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index d9257a48..a0f69743 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -353,8 +353,12 @@ impl TableUpdate {
}
/// Delete rows by row ID using deletion vectors.
- pub async fn delete_by_row_id(&self, row_ids: Vec<i64>) ->
crate::Result<Vec<CommitMessage>> {
- if row_ids.is_empty() {
+ pub async fn delete_by_row_id(
+ &self,
+ row_ids: impl IntoIterator<Item = i64>,
+ ) -> crate::Result<Vec<CommitMessage>> {
+ let mut row_ids = row_ids.into_iter().peekable();
+ if row_ids.peek().is_none() {
return Ok(Vec::new());
}
// The high-level updater selects a read snapshot; the low-level
diff --git a/crates/paimon/src/table/table_update_plan_tests.rs
b/crates/paimon/src/table/table_update_plan_tests.rs
index a86c4fd8..764871cd 100644
--- a/crates/paimon/src/table/table_update_plan_tests.rs
+++ b/crates/paimon/src/table/table_update_plan_tests.rs
@@ -240,7 +240,12 @@ async fn
planned_delete_preserves_unselected_bucket_deletion_vectors() {
update
.with_plan(Plan::from_splits(plan.snapshot_id(), vec![selected]))
.unwrap();
- let messages = update.delete_by_row_id(vec![1]).await.unwrap();
+ append(&table, 30).await;
+ let row_ids = Int64Array::from(vec![-1, 1, 1, -1]).slice(1, 2);
+ let messages = update
+ .delete_by_row_id(row_ids.values().iter().copied())
+ .await
+ .unwrap();
assert_eq!(messages[0].check_from_snapshot, Some(3));
assert_eq!(messages[0].deleted_index_files, vec![previous_index]);
let ranges = messages[0].new_index_files[0]
@@ -265,7 +270,20 @@ async fn
planned_delete_preserves_unselected_bucket_deletion_vectors() {
.try_collect()
.await
.unwrap();
- assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 0);
+ let values: Vec<i32> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .iter()
+ .copied()
+ })
+ .collect();
+ assert_eq!(values, vec![30]);
}
#[tokio::test]
diff --git a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
index e25d7fd4..0fc6f89a 100644
--- a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
+++ b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
@@ -561,7 +561,7 @@ async fn
limit_and_deletion_vectors_do_not_trigger_sidecar_reads() {
// Keeping only one live row must not make an otherwise full read use a
// sidecar either. All the sidecars above are absent, but primaries remain.
- let deletions = update.delete_by_row_id((1..100).collect()).await.unwrap();
+ let deletions = update.delete_by_row_id(1..100).await.unwrap();
table
.new_write_builder()
.new_commit()