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 c60d70ee [core] Expose postpone bucket plans through the write builder
(#1200)
c60d70ee is described below
commit c60d70eedeeab7a62780c3f0216d12edece5c4ef
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 22 22:31:25 2026 +0800
[core] Expose postpone bucket plans through the write builder (#1200)
---
bindings/python/project-description.md | 32 +++
bindings/python/python/pypaimon_rust/__init__.py | 7 +
.../python/python/pypaimon_rust/datafusion.pyi | 41 ++--
.../pypaimon_rust/{__init__.py => uri_reader.py} | 18 +-
bindings/python/src/write.rs | 213 ++++++++++++++++++--
bindings/python/tests/test_blob_uri_reader.py | 4 +-
bindings/python/tests/test_table_commit.py | 12 +-
bindings/python/tests/test_uri_reader_protocols.py | 61 ++++++
crates/paimon/src/table/authorized_write_tests.rs | 53 +++++
crates/paimon/src/table/postpone_bucket_plan.rs | 136 ++++++++++++-
.../table/postpone_fixed_bucket_write_builder.rs | 217 ++++++++++++++++++++-
crates/paimon/src/table/table_upsert.rs | 7 +-
12 files changed, 748 insertions(+), 53 deletions(-)
diff --git a/bindings/python/project-description.md
b/bindings/python/project-description.md
index 44633f86..86c40860 100644
--- a/bindings/python/project-description.md
+++ b/bindings/python/project-description.md
@@ -53,6 +53,38 @@ ctx.sql("INSERT INTO paimon.my_db.users VALUES (1, 'alice'),
(2, 'bob')")
batches = ctx.sql("SELECT * FROM paimon.my_db.users")
```
+### Blob Source Streams and Commit Identity
+
+The write and update APIs accept a `UriReaderFactory` for custom Blob reference
+sources. Both stream protocols are public runtime imports and support
structural
+typing; implementations do not need to inherit from them.
+
+```python
+from io import BytesIO
+from pypaimon_rust.datafusion import UriReader, UriReaderFactory
+
+payloads = {"custom://example": b"content"}
+
+class Reader:
+ def new_input_stream(self, uri: str):
+ return BytesIO(payloads[uri])
+
+class Factory:
+ def create(self, uri: str) -> UriReader:
+ return Reader()
+
+factory: UriReaderFactory = Factory()
+builder = table.new_batch_write_builder().with_commit_user("my-job")
+writer = builder.new_write().with_blob_uri_reader_factory(factory)
+update = builder.new_update().with_blob_uri_reader_factory(factory)
+```
+
+Rust copies the requested Blob ranges and closes the source streams. Pass
`None`
+to clear the factory. Configure a writer before its first write. Batch and
stream
+updates, including incremental row-ID updaters, expose the same chainable
setter.
+`with_commit_user` is available on batch, stream, and postpone fixed bucket
write
+builders, and the identity applies to both writers and committers from that
builder.
+
### Multimodal SQL Helpers
`SQLContext` also registers Python scalar UDFs for common BLOB media and vector
diff --git a/bindings/python/python/pypaimon_rust/__init__.py
b/bindings/python/python/pypaimon_rust/__init__.py
index b36002b7..9838f8d2 100644
--- a/bindings/python/python/pypaimon_rust/__init__.py
+++ b/bindings/python/python/pypaimon_rust/__init__.py
@@ -16,3 +16,10 @@
# under the License.
from .pypaimon_rust import *
+from .pypaimon_rust import datafusion as datafusion
+from .uri_reader import UriReader as UriReader, UriReaderFactory as
UriReaderFactory
+
+# The native submodule is registered during extension initialization. Export
the
+# Python stream protocols beside its writers and update APIs.
+datafusion.UriReader = UriReader
+datafusion.UriReaderFactory = UriReaderFactory
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index ad573e03..427e9810 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -18,22 +18,18 @@
from datetime import datetime
from os import PathLike
from typing import (
- Any, BinaryIO, Callable, Dict, Iterable, List, Literal, Optional,
Protocol, Sequence, Tuple,
+ Any, Callable, Dict, Iterable, List, Literal, Optional, Sequence, Tuple,
TypeAlias, Union,
)
import pyarrow
+from .uri_reader import UriReader as UriReader, UriReaderFactory as
UriReaderFactory
+
ArrowTypeLike: TypeAlias = Union[pyarrow.DataType, pyarrow.Field, str]
InputFieldsLike: TypeAlias = Union[ArrowTypeLike, Sequence[ArrowTypeLike]]
VolatilityLike: TypeAlias = Union[str, Any]
-class _UriReader(Protocol):
- def new_input_stream(self, uri: str) -> BinaryIO: ...
-
-class _UriReaderFactory(Protocol):
- def create(self, uri: str) -> _UriReader: ...
-
class DataField:
def id(self) -> int: ...
def name(self) -> str: ...
@@ -462,7 +458,7 @@ class CommitMessage:
class BatchTableWrite:
def with_blob_uri_reader_factory(
- self, factory: Optional[_UriReaderFactory]
+ self, factory: Optional[UriReaderFactory]
) -> "BatchTableWrite":
"""Use custom source streams for Blob references; Rust copies bounded
ranges, reuses consecutive sources and closes them on completion."""
@@ -481,7 +477,9 @@ class BatchTableWrite:
...
class BatchTableUpdate:
- def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None:
...
+ def with_blob_uri_reader_factory(
+ self, factory: Optional[UriReaderFactory]
+ ) -> "BatchTableUpdate": ...
def merge_into(
self, source: Optional[Union[pyarrow.Table, Table]], *, on:
Sequence[Tuple[str, str]],
when_matched: Sequence[Dict[str, Any]], when_not_matched:
Sequence[Dict[str, Any]]
@@ -531,7 +529,9 @@ class BatchTableUpdate:
...
class TableUpdateByRowId:
- def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None:
...
+ def with_blob_uri_reader_factory(
+ self, factory: Optional[UriReaderFactory]
+ ) -> "TableUpdateByRowId": ...
def update_columns(self, data: pyarrow.Table, column_names: List[str]) ->
List[CommitMessage]:
"""Stage one input table; return the cumulative messages to commit
once."""
...
@@ -540,7 +540,9 @@ class TableUpdateByRowId:
def _abort(self) -> None: ...
class StreamTableUpdate:
- def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None:
...
+ def with_blob_uri_reader_factory(
+ self, factory: Optional[UriReaderFactory]
+ ) -> "StreamTableUpdate": ...
def merge_into(
self, source: Optional[Union[pyarrow.Table, Table]], *, on:
Sequence[Tuple[str, str]],
when_matched: Sequence[Dict[str, Any]], when_not_matched:
Sequence[Dict[str, Any]],
@@ -591,7 +593,7 @@ class StreamTableUpdate:
class StreamTableWrite:
def with_blob_uri_reader_factory(
- self, factory: Optional[_UriReaderFactory]
+ self, factory: Optional[UriReaderFactory]
) -> "StreamTableWrite": ...
def with_write_type(self, columns: List[str]) -> "StreamTableWrite": ...
def with_blob_consumer(
@@ -740,8 +742,8 @@ class StreamTableCommit:
...
class BatchWriteBuilder:
- def _with_commit_user(self, commit_user: str) -> "BatchWriteBuilder":
- """Internal PyPaimon bridge for commits produced by an external Python
writer."""
+ def with_commit_user(self, commit_user: str) -> "BatchWriteBuilder":
+ """Set the commit identity shared by this builder's writers and
committers."""
...
def with_overwrite(self, static_partition: Optional[Dict[str, Any]] = {})
-> "BatchWriteBuilder":
"""Configure both writer and committer. Explicit None restores append.
@@ -772,7 +774,16 @@ class PostponeFixedBucketWriteBuilder:
Unknown partitions buffer until preparation; known layouts write directly.
Rescaling and compaction are not performed.
"""
- def _with_commit_user(self, commit_user: str) ->
"PostponeFixedBucketWriteBuilder": ...
+ def with_commit_user(self, commit_user: str) ->
"PostponeFixedBucketWriteBuilder": ...
+ def resolve_bucket_plan(self) -> pyarrow.RecordBatch:
+ """Known partition layouts from the latest writer snapshot, without
SELECT.
+
+ Uses the with_bucket_plan schema. Explicit plans take precedence;
+ overwrite otherwise returns no existing layouts. Configured defaults
+ do not create partition rows in this metadata plan.
+ This method does not infer bucket counts for new input partitions.
+ """
+ ...
def with_bucket_plan(self, plan: pyarrow.RecordBatch) ->
"PostponeFixedBucketWriteBuilder":
"""Partition columns in schema order followed by total_buckets: int32.
diff --git a/bindings/python/python/pypaimon_rust/__init__.py
b/bindings/python/python/pypaimon_rust/uri_reader.py
similarity index 62%
copy from bindings/python/python/pypaimon_rust/__init__.py
copy to bindings/python/python/pypaimon_rust/uri_reader.py
index b36002b7..3beb5d7e 100644
--- a/bindings/python/python/pypaimon_rust/__init__.py
+++ b/bindings/python/python/pypaimon_rust/uri_reader.py
@@ -15,4 +15,20 @@
# specific language governing permissions and limitations
# under the License.
-from .pypaimon_rust import *
+"""Source stream contracts for managed Blob URI reads."""
+
+from typing import BinaryIO, Protocol
+
+__all__ = ["UriReader", "UriReaderFactory"]
+
+
+class UriReader(Protocol):
+ """Open readable binary streams; Rust closes each stream after use."""
+
+ def new_input_stream(self, uri: str) -> BinaryIO: ...
+
+
+class UriReaderFactory(Protocol):
+ """Select a URI reader while leaving payload copying to Rust Core."""
+
+ def create(self, uri: str) -> UriReader: ...
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index 1ac21b05..299730b0 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -19,7 +19,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use arrow::datatypes::Schema as ArrowSchema;
-use arrow::pyarrow::FromPyArrow;
+use arrow::pyarrow::{FromPyArrow, ToPyArrow};
use arrow::record_batch::RecordBatch;
use paimon::spec::{BlobConsumer, BlobDescriptor, CoreOptions, DataType, Datum,
RowType};
use paimon::table::{
@@ -165,8 +165,8 @@ impl PyBatchWriteBuilder {
#[pymethods]
impl PyBatchWriteBuilder {
- /// Internal PyPaimon bridge: retain the identity of the external Python
writer.
- fn _with_commit_user(
+ /// Set the commit identity shared by this builder's writers and
committers.
+ fn with_commit_user(
mut slf: PyRefMut<'_, Self>,
commit_user: String,
) -> PyResult<PyRefMut<'_, Self>> {
@@ -412,7 +412,28 @@ impl PyPostponeFixedBucketWriteBuilder {
#[pymethods]
impl PyPostponeFixedBucketWriteBuilder {
- fn _with_commit_user(
+ /// Existing partitions and their bucket counts, using writer metadata
access.
+ fn resolve_bucket_plan(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
+ let table = &self.batch.context.table;
+ let mut builder = table
+ .new_postpone_fixed_bucket_write_builder()
+ .map_err(to_py_err)?;
+ if let Some(plan) = &self.plan {
+ builder = builder.with_bucket_plan(plan.clone());
+ }
+ if self.batch.static_partition.is_some() {
+ builder = builder.with_overwrite();
+ }
+ let plan = py
+ .detach(|| runtime().block_on(builder.resolve_bucket_plan()))
+ .map_err(to_py_err)?;
+ plan.to_arrow(table)
+ .map_err(to_py_err)?
+ .to_pyarrow(py)
+ .map(Bound::unbind)
+ }
+
+ fn with_commit_user(
mut slf: PyRefMut<'_, Self>,
commit_user: String,
) -> PyResult<PyRefMut<'_, Self>> {
@@ -994,14 +1015,14 @@ pub struct PyTableUpdateByRowId {
#[pymethods]
impl PyTableUpdateByRowId {
- fn _with_blob_uri_reader_factory(
- &mut self,
- py: Python<'_>,
+ #[pyo3(signature = (factory))]
+ fn with_blob_uri_reader_factory(
+ mut slf: PyRefMut<'_, Self>,
factory: Option<Py<PyAny>>,
- ) -> PyResult<()> {
- let factory = optional_uri_reader_factory(factory, py)?;
- self.inner.with_blob_uri_reader_factory(factory);
- Ok(())
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ let factory = optional_uri_reader_factory(factory, slf.py())?;
+ slf.inner.with_blob_uri_reader_factory(factory);
+ Ok(slf)
}
fn update_columns(
@@ -1043,12 +1064,14 @@ pub struct PyStreamTableUpdate {
#[pymethods]
impl PyStreamTableUpdate {
- fn _with_blob_uri_reader_factory(
- &mut self,
- py: Python<'_>,
+ #[pyo3(signature = (factory))]
+ fn with_blob_uri_reader_factory(
+ mut slf: PyRefMut<'_, Self>,
factory: Option<Py<PyAny>>,
- ) -> PyResult<()> {
- self.context.with_blob_uri_reader_factory(factory, py)
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ let py = slf.py();
+ slf.context.with_blob_uri_reader_factory(factory, py)?;
+ Ok(slf)
}
#[pyo3(signature = (source, *, on, when_matched, when_not_matched,
commit_identifier))]
@@ -1170,12 +1193,14 @@ impl PyBatchTableUpdate {
self.context.delete_by_predicate(py, predicate)
}
- fn _with_blob_uri_reader_factory(
- &mut self,
- py: Python<'_>,
+ #[pyo3(signature = (factory))]
+ fn with_blob_uri_reader_factory(
+ mut slf: PyRefMut<'_, Self>,
factory: Option<Py<PyAny>>,
- ) -> PyResult<()> {
- self.context.with_blob_uri_reader_factory(factory, py)
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ let py = slf.py();
+ slf.context.with_blob_uri_reader_factory(factory, py)?;
+ Ok(slf)
}
#[pyo3(signature = (source, *, on, when_matched, when_not_matched))]
@@ -1759,6 +1784,152 @@ mod tests {
use paimon::spec::{BigIntType, IntType, Schema, TableSchema, VarCharType};
use pyo3::ffi::c_str;
+ #[test]
+ fn public_uri_factory_and_commit_user_setters_are_chainable() {
+ Python::attach(|py| {
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ io.clone(),
+ Identifier::new("default", "public_uri_setters"),
+ "memory:/public_uri_setters".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let fixed_schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "-2")
+ .build()
+ .unwrap();
+ let fixed = Arc::new(Table::new(
+ io,
+ Identifier::new("default", "public_commit_user"),
+ "memory:/public_commit_user".into(),
+ TableSchema::new(0, &fixed_schema),
+ None,
+ ));
+ let batch = Py::new(py,
PyBatchWriteBuilder::new(table.clone())).unwrap();
+ let stream = Py::new(py,
PyStreamWriteBuilder::new(table)).unwrap();
+ let fixed =
+ Py::new(py,
PyPostponeFixedBucketWriteBuilder::new(fixed).unwrap()).unwrap();
+ let locals = PyDict::new(py);
+ locals.set_item("batch", batch).unwrap();
+ locals.set_item("stream", stream).unwrap();
+ locals.set_item("fixed", fixed).unwrap();
+ py.run(
+ c_str!(
+ r#"
+import io
+
+class Reader:
+ def new_input_stream(self, uri):
+ return io.BytesIO(uri.encode())
+
+class Factory:
+ def create(self, uri):
+ return Reader()
+
+factory = Factory()
+for builder in [batch, stream, fixed]:
+ assert builder.with_commit_user('public-job') is builder
+ assert not hasattr(builder, '_with_commit_user')
+ for invalid in ['', '..', 'name/part']:
+ try:
+ builder.with_commit_user(invalid)
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('invalid commit user accepted')
+
+for builder in [batch, stream]:
+ writer = builder.new_write()
+ assert writer.with_blob_uri_reader_factory(factory) is writer
+ assert writer.with_blob_uri_reader_factory(None) is writer
+ writer.close()
+ update = builder.new_update()
+ row_id = (update.new_update_by_row_id() if builder is batch else
+ update.new_update_by_row_id(7))
+ for operation in [update, row_id]:
+ assert operation.with_blob_uri_reader_factory(factory) is operation
+ try:
+ operation.with_blob_uri_reader_factory(type('Invalid', (),
{'create': None})())
+ except TypeError:
+ pass
+ else:
+ raise AssertionError('factory without callable create accepted')
+ assert operation.with_blob_uri_reader_factory(None) is operation
+ assert not hasattr(operation, '_with_blob_uri_reader_factory')
+"#
+ ),
+ Some(&locals),
+ Some(&locals),
+ )
+ .unwrap();
+ });
+ }
+
+ #[test]
+ fn
postpone_resolve_bucket_plan_binding_round_trips_arrow_and_overwrite_precedence()
{
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("pt", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .partition_keys(["pt"])
+ .primary_key(["pt", "id"])
+ .option("bucket", "-2")
+ .option("postpone.default-bucket-num", "7")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "bucket_plan_binding"),
+ "memory:/bucket_plan_binding".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let builder = Py::new(
+ py,
+ PyPostponeFixedBucketWriteBuilder::new(table.clone()).unwrap(),
+ )
+ .unwrap();
+ let overwrite =
+ Py::new(py,
PyPostponeFixedBucketWriteBuilder::new(table).unwrap()).unwrap();
+ let locals = PyDict::new(py);
+ locals.set_item("builder", builder).unwrap();
+ locals.set_item("overwrite", overwrite).unwrap();
+ py.run(
+ c_str!(
+ r#"
+import pyarrow as pa
+empty = builder.resolve_bucket_plan()
+assert not hasattr(builder, 'bucket_plan')
+assert isinstance(empty, pa.RecordBatch)
+assert empty.schema.names == ['pt', 'total_buckets']
+assert empty.num_rows == 0
+assert empty.schema.field('total_buckets') == pa.field('total_buckets',
pa.int32(), nullable=False)
+plan = pa.RecordBatch.from_arrays([pa.array(['b', 'a']), pa.array([3, 2],
type=pa.int32())],
+ names=['pt', 'total_buckets'])
+builder.with_bucket_plan(plan)
+assert sorted(zip(*builder.resolve_bucket_plan().to_pydict().values())) ==
[('a', 2), ('b', 3)]
+builder.with_overwrite()
+assert builder.resolve_bucket_plan().num_rows == 2
+overwrite.with_overwrite()
+assert overwrite.resolve_bucket_plan().num_rows == 0
+"#
+ ),
+ Some(&locals),
+ Some(&locals),
+ )
+ .unwrap();
+ });
+ }
+
#[test]
fn replaced_blob_consumers_are_visible_to_writer_cycle_collection() {
Python::attach(|py| {
diff --git a/bindings/python/tests/test_blob_uri_reader.py
b/bindings/python/tests/test_blob_uri_reader.py
index 6c0fbaac..54bbc54a 100644
--- a/bindings/python/tests/test_blob_uri_reader.py
+++ b/bindings/python/tests/test_blob_uri_reader.py
@@ -210,7 +210,7 @@ def
test_update_factory_errors_keep_identity_and_existing_data(tmp_path, stream,
builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
update = builder.new_update()
- update._with_blob_uri_reader_factory(Factory())
+ assert update.with_blob_uri_reader_factory(Factory()) is update
reference = _reference(source_file.as_uri())
source = pa.Table.from_batches([_batch([0], [reference])])
data = pa.table({'_ROW_ID': [row_id], 'payload': pa.array([reference],
type=pa.large_binary())})
@@ -252,7 +252,7 @@ def
test_predicate_updates_share_blob_factory_across_matched_file_groups(tmp_pat
factory = ReaderFactory()
builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
update = builder.new_update()
- update._with_blob_uri_reader_factory(factory)
+ assert update.with_blob_uri_reader_factory(factory) is update
reference = descriptor(2, -1)
read_columns = None
if assignment == 'scalar':
diff --git a/bindings/python/tests/test_table_commit.py
b/bindings/python/tests/test_table_commit.py
index e6ee4bdd..1fa5ff4a 100644
--- a/bindings/python/tests/test_table_commit.py
+++ b/bindings/python/tests/test_table_commit.py
@@ -85,7 +85,7 @@ def test_public_api_separates_batch_and_stream(tmp_path):
factory(**kwargs)
batch = table.new_batch_write_builder()
stream = table.new_stream_write_builder()
- assert not hasattr(batch, "with_commit_user")
+ assert batch.with_commit_user("batch-job") is batch
assert not hasattr(stream, "with_overwrite")
for obj in (batch, stream, batch.new_commit(), stream.new_commit()):
assert not hasattr(obj, "deserialize_commit_message")
@@ -208,11 +208,11 @@ def test_empty_stream_snapshot_properties(tmp_path):
@pytest.mark.parametrize("overwrite", [False, True])
@pytest.mark.parametrize("serialized", [False, True])
-def test_batch_bridge_preserves_external_commit_user(tmp_path, overwrite,
serialized):
+def test_public_batch_commit_user_survives_serialization(tmp_path, overwrite,
serialized):
table = _table(tmp_path, primary_key=True, options={"bucket": "1"})
_append(table, [1], [10])
builder = table.new_batch_write_builder()
- assert builder._with_commit_user("python-batch-job") is builder
+ assert builder.with_commit_user("python-batch-job") is builder
if overwrite:
builder.with_overwrite()
messages = _prepare(builder, [2], [20])
@@ -226,11 +226,11 @@ def
test_batch_bridge_preserves_external_commit_user(tmp_path, overwrite, serial
@pytest.mark.parametrize("user", ["", "../job", "a/b"])
-def test_invalid_batch_bridge_commit_user_preserves_identity(tmp_path, user):
+def test_invalid_public_batch_commit_user_preserves_identity(tmp_path, user):
table = _table(tmp_path)
- builder =
table.new_batch_write_builder()._with_commit_user("python-batch-job")
+ builder =
table.new_batch_write_builder().with_commit_user("python-batch-job")
with pytest.raises(ValueError):
- builder._with_commit_user(user)
+ builder.with_commit_user(user)
builder.new_commit().commit(_prepare(builder, [1], [10]))
assert _snapshot(table)["commitUser"] == "python-batch-job"
assert _rows(table) == [1]
diff --git a/bindings/python/tests/test_uri_reader_protocols.py
b/bindings/python/tests/test_uri_reader_protocols.py
new file mode 100644
index 00000000..d1039e74
--- /dev/null
+++ b/bindings/python/tests/test_uri_reader_protocols.py
@@ -0,0 +1,61 @@
+# 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.
+
+"""The package bootstrap exports stream protocols without an SDK rebuild."""
+
+from pathlib import Path
+import subprocess
+import sys
+
+
+def test_public_uri_protocols_are_runtime_imports():
+ source = Path(__file__).resolve().parents[1] / 'python'
+ code = r"""
+import io
+import sys
+import types
+
+# Model the dynamic submodule created by the extension. Load the real Python
+# package bootstrap and protocol source without loading an installed extension.
+engine = types.ModuleType('pypaimon_rust.pypaimon_rust')
+engine.datafusion = types.ModuleType('pypaimon_rust.datafusion')
+engine.__all__ = ['datafusion']
+sys.modules[engine.__name__] = engine
+sys.modules[engine.datafusion.__name__] = engine.datafusion
+sys.path.insert(0, sys.argv[1])
+
+from pypaimon_rust import UriReader as TopReader, UriReaderFactory as
TopFactory
+from pypaimon_rust.datafusion import UriReader, UriReaderFactory
+from pypaimon_rust.uri_reader import UriReader as SourceReader
+assert UriReader is TopReader is SourceReader
+assert UriReaderFactory is TopFactory
+
+class Reader(UriReader):
+ def new_input_stream(self, uri):
+ return io.BytesIO(uri.encode())
+
+class Factory(UriReaderFactory):
+ def create(self, uri) -> UriReader:
+ return Reader()
+
+with Factory().create('custom://source').new_input_stream('custom://source')
as stream:
+ assert stream.read() == b'custom://source'
+
+assert not hasattr(engine.datafusion, '_UriReader')
+assert not hasattr(engine.datafusion, '_UriReaderFactory')
+"""
+ subprocess.run([sys.executable, '-c', code, str(source)], check=True)
diff --git a/crates/paimon/src/table/authorized_write_tests.rs
b/crates/paimon/src/table/authorized_write_tests.rs
index 59d5a0df..c78a7b15 100644
--- a/crates/paimon/src/table/authorized_write_tests.rs
+++ b/crates/paimon/src/table/authorized_write_tests.rs
@@ -296,6 +296,59 @@ impl CommitCallback for Callback {
}
}
+#[tokio::test]
+async fn
rest_postpone_resolve_bucket_plan_requires_write_identity_without_select_permission()
{
+ let fixture = RestTable::new(
+ true,
+ &[("bucket", "-2"), ("postpone.default-bucket-num", "2")],
+ )
+ .await;
+ fixture
+ .append("fixed", &[(1, "a", 10), (2, "b", 20)], true)
+ .await;
+ let table = fixture.table.copy_with_pinned_snapshot(None);
+ // The REST response already carries the selected snapshot. The filesystem
+ // copy is not required to inspect its manifests through a writer builder.
+ table
+ .file_io()
+ .delete_file(&table.snapshot_manager().snapshot_path(1))
+ .await
+ .unwrap();
+ let exported = table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&table)
+ .unwrap();
+ assert_eq!(exported.num_rows(), 2);
+ assert_eq!(exported.schema().field(0).name(), "p");
+ assert_eq!(
+ exported
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values(),
+ &[2, 2]
+ );
+ assert_eq!(fixture.state.query_requests.load(Ordering::SeqCst), 0);
+ let mut schema = serde_json::to_value(table.schema()).unwrap();
+ schema["fields"][2]["name"] = serde_json::json!("another_value");
+ let unbound = table
+ .copy_with_resolved_schema(serde_json::from_value(schema).unwrap(),
"main")
+ .unwrap();
+ assert!(unbound.query_auth_session().is_none());
+ assert!(unbound
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .resolve_bucket_plan()
+ .await
+ .is_err());
+ assert_eq!(fixture.state.query_requests.load(Ordering::SeqCst), 0);
+}
+
#[tokio::test]
async fn rest_stream_recovery_and_callbacks_do_not_require_select_permission()
{
let fixture = RestTable::new(false, &[]).await;
diff --git a/crates/paimon/src/table/postpone_bucket_plan.rs
b/crates/paimon/src/table/postpone_bucket_plan.rs
index 83bba51f..ff089650 100644
--- a/crates/paimon/src/table/postpone_bucket_plan.rs
+++ b/crates/paimon/src/table/postpone_bucket_plan.rs
@@ -15,10 +15,11 @@
// specific language governing permissions and limitations
// under the License.
-use crate::spec::batch_to_serialized_bytes;
+use crate::spec::{batch_to_serialized_bytes, BinaryRow, Snapshot};
use crate::table::{Table, TableScan};
use crate::Result;
-use arrow_array::{Array, Int32Array, RecordBatch};
+use arrow_array::{new_empty_array, Array, ArrayRef, Int32Array, RecordBatch};
+use arrow_schema::{DataType, Field, Schema};
use std::collections::HashMap;
use std::sync::Arc;
@@ -124,6 +125,60 @@ impl PostponeBucketPlan {
})
}
+ /// Export known partitions using the same schema accepted by `from_arrow`.
+ /// A configured default is not an existing partition and is not exported.
+ pub fn to_arrow(&self, table: &Table) -> Result<RecordBatch> {
+ let partition_fields = table.schema().partition_fields();
+ let mut fields =
crate::arrow::build_target_arrow_schema(&partition_fields)?
+ .fields()
+ .to_vec();
+ fields.push(Arc::new(Field::new(
+ POSTPONE_BUCKET_PLAN_TOTAL_BUCKETS_FIELD,
+ DataType::Int32,
+ false,
+ )));
+ let mut entries = self.bucket_counts.iter().collect::<Vec<_>>();
+ entries.sort_unstable_by_key(|(partition, _)| *partition);
+ let partitions = entries
+ .iter()
+ .map(|(bytes, _)| {
+ let row = BinaryRow::from_serialized_bytes(bytes)?;
+ if row.arity() as usize != partition_fields.len() {
+ return Err(data_invalid(
+ "Postpone bucket plan partition arity does not match
the table schema",
+ ));
+ }
+ Ok(row)
+ })
+ .collect::<Result<Vec<_>>>()?;
+ let mut columns = Vec::<ArrayRef>::with_capacity(fields.len());
+ for (index, field) in partition_fields.iter().enumerate() {
+ let arrays = partitions
+ .iter()
+ .map(|partition| {
+ crate::arrow::partition::partition_array(partition, index,
field.data_type(), 1)
+ })
+ .collect::<Result<Vec<_>>>()?;
+ let column = if arrays.is_empty() {
+ new_empty_array(fields[index].data_type())
+ } else {
+ arrow_select::concat::concat(
+ &arrays
+ .iter()
+ .map(|array| array.as_ref())
+ .collect::<Vec<_>>(),
+ )
+ .map_err(|error| data_invalid(error.to_string()))?
+ };
+ columns.push(column);
+ }
+ columns.push(Arc::new(Int32Array::from_iter_values(
+ entries.iter().map(|(_, count)| **count),
+ )));
+ RecordBatch::try_new(Arc::new(Schema::new(fields)), columns)
+ .map_err(|error| data_invalid(error.to_string()))
+ }
+
pub(crate) fn with_default_bucket_num(default_bucket_num: i32) -> Self {
Self {
default_bucket_num: Some(default_bucket_num),
@@ -142,9 +197,18 @@ impl PostponeBucketPlan {
return Ok(());
}
let snapshot =
table.snapshot_manager().get_snapshot(snapshot_id).await?;
+ self.load_existing_buckets_from_snapshot(table, &snapshot)
+ .await
+ }
+
+ pub(super) async fn load_existing_buckets_from_snapshot(
+ &mut self,
+ table: &Table,
+ snapshot: &Snapshot,
+ ) -> Result<()> {
let entries = TableScan::new(table, None, vec![], None, None, None)
.with_scan_all_files()
- .plan_manifest_entries(&snapshot)
+ .plan_manifest_entries(snapshot)
.await?;
let mut counts = HashMap::new();
for entry in entries {
@@ -174,3 +238,69 @@ impl PostponeBucketPlan {
.or(self.default_bucket_num)
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::table::table_write::tests::test_file_io;
+ use arrow_array::StringArray;
+
+ #[test]
+ fn arrow_plan_round_trip_keeps_null_partition_and_excludes_default_rows() {
+ let schema = crate::spec::Schema::builder()
+ .column(
+ "pt",
+
crate::spec::DataType::VarChar(crate::spec::VarCharType::string_type()),
+ )
+ .column(
+ "id",
+ crate::spec::DataType::Int(crate::spec::IntType::new()),
+ )
+ .primary_key(["pt", "id"])
+ .partition_keys(["pt"])
+ .option("bucket", "-2")
+ .option("primary-key.nullable", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ test_file_io(),
+ crate::catalog::Identifier::new("default", "bucket_plan_arrow"),
+ "memory:/bucket_plan_arrow".into(),
+ crate::spec::TableSchema::new(0, &schema),
+ None,
+ );
+ let batch = RecordBatch::try_new(
+ Arc::new(Schema::new(vec![
+ Field::new("pt", DataType::Utf8, true),
+ Field::new(
+ POSTPONE_BUCKET_PLAN_TOTAL_BUCKETS_FIELD,
+ DataType::Int32,
+ false,
+ ),
+ ])),
+ vec![
+ Arc::new(StringArray::from(vec![Some("b"), None, Some("a")])),
+ Arc::new(Int32Array::from(vec![2, 3, 4])),
+ ],
+ )
+ .unwrap();
+ let plan = PostponeBucketPlan::from_arrow(&table, &batch).unwrap();
+ let exported = plan.to_arrow(&table).unwrap();
+ assert_eq!(exported.schema().field(0).name(), "pt");
+ assert_eq!(exported.schema().field(0).data_type(), &DataType::Utf8);
+ assert_eq!(exported.schema().field(1), batch.schema().field(1));
+ assert_eq!(exported.num_rows(), 3);
+ assert_eq!(
+ PostponeBucketPlan::from_arrow(&table, &exported)
+ .unwrap()
+ .bucket_counts,
+ plan.bucket_counts
+ );
+ let expected_schema = exported.schema();
+ let default = PostponeBucketPlan::with_default_bucket_num(7);
+ let exported = default.to_arrow(&table).unwrap();
+ assert_eq!(exported.schema(), expected_schema);
+ assert_eq!(exported.num_rows(), 0);
+ assert_eq!(default.total_buckets(&[]), Some(7));
+ }
+}
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
b/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
index 661f3207..21ebc714 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
@@ -18,7 +18,9 @@
use super::postpone_fixed_bucket_router::validate_postpone_fixed_bucket_table;
use crate::resource::ResourceContext;
use crate::spec::CoreOptions;
-use crate::table::write_builder::{ensure_table_write_allowed,
validate_commit_user};
+use crate::table::write_builder::{
+ ensure_table_write_allowed, ensure_table_write_authorized,
validate_commit_user,
+};
use crate::table::{
PostponeBucketPlan, PostponeFixedBucketTableCommit,
PostponeFixedBucketTableWrite, Table,
};
@@ -71,6 +73,29 @@ impl<'a> PostponeFixedBucketWriteBuilder<'a> {
self
}
+ /// Resolve routing metadata before distributed input is materialized.
+ ///
+ /// Existing partitions are read from the latest write baseline,
independently
+ /// of historical read selectors. This uses writer metadata access and does
+ /// not require a SELECT grant. Overwrite starts with no existing layout;
+ /// an explicitly supplied plan takes precedence over that default.
+ /// Bucket counts for new input partitions are not inferred by this method.
+ pub async fn resolve_bucket_plan(&self) -> Result<PostponeBucketPlan> {
+ ensure_table_write_authorized(self.table)?;
+ if let Some(plan) = &self.bucket_plan {
+ return Ok(plan.clone());
+ }
+ if self.overwrite {
+ return Ok(PostponeBucketPlan::default());
+ }
+ let mut plan = PostponeBucketPlan::default();
+ if let Some(snapshot) =
self.table.snapshot_manager().get_latest_snapshot().await? {
+ plan.load_existing_buckets_from_snapshot(self.table, &snapshot)
+ .await?;
+ }
+ Ok(plan)
+ }
+
pub fn new_commit(&self) -> PostponeFixedBucketTableCommit {
PostponeFixedBucketTableCommit::new(self.table,
self.commit_user.clone(), self.overwrite)
}
@@ -123,6 +148,196 @@ mod tests {
PostponeBucketPlan::from_arrow(table, &batch).unwrap()
}
+ #[tokio::test]
+ async fn
resolve_bucket_plan_obeys_explicit_and_overwrite_precedence_without_default_rows()
{
+ let io = test_file_io();
+ let path = "memory:/builder_bucket_plan_modes";
+ setup_dirs(&io, path).await;
+ let table =
+ test_postpone_pk_table(&io,
path).copy_with_options(std::collections::HashMap::from([
+ ("postpone.default-bucket-num".into(), "7".into()),
+ ]));
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ assert_eq!(
+ builder
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&table)
+ .unwrap()
+ .num_rows(),
+ 0
+ );
+ let explicit = bucket_plan(&table, 3);
+ let builder = builder.with_overwrite().with_bucket_plan(explicit);
+ let exported = builder
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&table)
+ .unwrap();
+ assert_eq!(exported.num_rows(), 1);
+ assert_eq!(
+ exported
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 3
+ );
+ assert_eq!(
+ table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .with_overwrite()
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&table)
+ .unwrap()
+ .num_rows(),
+ 0
+ );
+ }
+
+ #[tokio::test]
+ async fn
resolve_bucket_plan_uses_latest_write_baseline_and_ignores_pending_files() {
+ let io = test_file_io();
+ let path = "memory:/builder_bucket_plan_latest";
+ setup_dirs(&io, path).await;
+ let table = test_postpone_pk_table(&io, path);
+ let pending = table.new_write_builder();
+ let mut writer = pending.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![10]))
+ .await
+ .unwrap();
+ pending
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ assert_eq!(
+ table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&table)
+ .unwrap()
+ .num_rows(),
+ 0
+ );
+ let fixed = table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .with_bucket_plan(bucket_plan(&table, 3));
+ let mut writer = fixed.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![2], vec![20]))
+ .await
+ .unwrap();
+ fixed
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let historical =
table.copy_with_options(std::collections::HashMap::from([(
+ "scan.snapshot-id".into(),
+ "1".into(),
+ )]));
+ let exported = historical
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&historical)
+ .unwrap();
+ assert_eq!(exported.num_rows(), 1);
+ assert_eq!(
+ exported
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 3
+ );
+ assert_eq!(
+ historical
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .with_overwrite()
+ .resolve_bucket_plan()
+ .await
+ .unwrap()
+ .to_arrow(&historical)
+ .unwrap()
+ .num_rows(),
+ 0
+ );
+ }
+
+ #[tokio::test]
+ async fn resolve_bucket_plan_rejects_conflicting_entries_in_one_bucket() {
+ use crate::spec::{Manifest, ManifestEntry, ManifestList};
+ let io = test_file_io();
+ let path = "memory:/builder_bucket_plan_conflict";
+ setup_dirs(&io, path).await;
+ let table = test_postpone_pk_table(&io, path);
+ let fixed = table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .with_bucket_plan(bucket_plan(&table, 1));
+ let mut writer = fixed.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![10]))
+ .await
+ .unwrap();
+ fixed
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let manager = table.snapshot_manager();
+ let snapshot = manager.get_snapshot(1).await.unwrap();
+ let list_path = manager.manifest_path(snapshot.delta_manifest_list());
+ let mut metas = ManifestList::read(&io, &list_path).await.unwrap();
+ let manifest_path = manager.manifest_path(metas[0].file_name());
+ let mut entries = Manifest::read(&io, &manifest_path).await.unwrap();
+ let original = &entries[0];
+ let mut file = original.file().clone();
+ file.file_name = "different-bucket-count.parquet".into();
+ entries.push(ManifestEntry::new(
+ *original.kind(),
+ original.partition().to_vec(),
+ original.bucket(),
+ 2,
+ file,
+ 2,
+ ));
+ Manifest::write(&io, &manifest_path, &entries)
+ .await
+ .unwrap();
+ let mut meta = serde_json::to_value(&metas[0]).unwrap();
+ meta["_NUM_ADDED_FILES"] = serde_json::json!(entries.len());
+ meta["_TOTAL_BUCKETS"] = serde_json::Value::Null;
+ metas[0] = serde_json::from_value(meta).unwrap();
+ ManifestList::write(&io, &list_path, &metas).await.unwrap();
+ let error = table
+ .new_postpone_fixed_bucket_write_builder()
+ .unwrap()
+ .resolve_bucket_plan()
+ .await
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("conflicting total bucket counts"));
+ }
+
#[tokio::test]
async fn test_postpone_fixed_bucket_builder_modes() {
let file_io = test_file_io();
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index e18521ea..c78abe56 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -34,8 +34,7 @@ use super::update_input::unique_column_index;
use super::upsert_key_matcher::UpsertKeyMatcher;
use super::write_batch_normalize::{normalize_blob_write_field,
normalize_write_array};
use crate::spec::{
- batch_to_serialized_bytes, extract_datum_from_arrow, BinaryRow,
CoreOptions, Predicate,
- PredicateBuilder,
+ batch_to_serialized_bytes, extract_datum_from_arrow, CoreOptions,
Predicate, PredicateBuilder,
};
use crate::table::source::{DataSplit, Plan};
use crate::table::{CommitMessage, Table};
@@ -562,8 +561,8 @@ mod tests {
use crate::catalog::Identifier;
use crate::io::FileIOBuilder;
use crate::spec::{
- DataEvolutionIndexSourceMeta, DataType as PaimonType, IndexManifest,
TimestampType,
- VarCharType,
+ BinaryRow, DataEvolutionIndexSourceMeta, DataType as PaimonType,
IndexManifest,
+ TimestampType, VarCharType,
};
use crate::spec::{IntType, Schema as PaimonSchema, TableSchema};
use crate::table::TableCommit;