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 b71af884 Complete native FUSE writes, postpone planning and MERGE
callbacks (#1165)
b71af884 is described below
commit b71af88414ba0020a9d0d0557c51eb2e43da8cae
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 08:56:04 2026 +0800
Complete native FUSE writes, postpone planning and MERGE callbacks (#1165)
---
bindings/c/src/tests.rs | 6 +-
bindings/c/src/write.rs | 6 +-
bindings/go/postpone_fixed_bucket_write.go | 6 +-
bindings/go/tests/paimon_test.go | 6 +-
bindings/python/README.md | 15 +-
.../python/python/pypaimon_rust/datafusion.pyi | 26 +-
bindings/python/src/merge_into.rs | 7 +-
bindings/python/src/write.rs | 25 +-
bindings/python/tests/test_table_commit.py | 23 ++
bindings/python/tests/test_update.py | 47 +++
crates/paimon/src/io/file_io/fuse.rs | 99 +++++
crates/paimon/src/spec/core_options.rs | 43 ++
crates/paimon/src/table/branch_write_tests.rs | 117 ++++++
crates/paimon/src/table/mod.rs | 38 +-
crates/paimon/src/table/partition_spec.rs | 284 +++++++++++++
crates/paimon/src/table/postpone_bucket_plan.rs | 6 +
crates/paimon/src/table/postpone_bucket_planner.rs | 437 +++++++++++++++++++++
.../src/table/postpone_fixed_bucket_router.rs | 4 +
.../src/table/postpone_fixed_bucket_write.rs | 57 ++-
.../table/postpone_fixed_bucket_write_builder.rs | 47 ++-
20 files changed, 1239 insertions(+), 60 deletions(-)
diff --git a/bindings/c/src/tests.rs b/bindings/c/src/tests.rs
index 8a8d535f..f445de6c 100644
--- a/bindings/c/src/tests.rs
+++ b/bindings/c/src/tests.rs
@@ -2528,9 +2528,9 @@ fn test_distributed_postpone_writers_share_bucket_plan() {
assert!(fixed.error.is_null());
assert!(!fixed.write_builder.is_null());
let write =
paimon_postpone_fixed_bucket_write_builder_new_write(fixed.write_builder);
- assert!(write.write.is_null());
- assert!(error_message(write.error).contains("bucket plan is
required"));
- paimon_error_free(write.error);
+ assert!(write.error.is_null());
+ assert!(!write.write.is_null());
+ paimon_postpone_fixed_bucket_table_write_free(write.write);
paimon_postpone_fixed_bucket_write_builder_free(fixed.write_builder);
let wb1 =
paimon_table_new_postpone_fixed_bucket_write_builder_with_commit_user(
diff --git a/bindings/c/src/write.rs b/bindings/c/src/write.rs
index 86f86e18..3282e0d5 100644
--- a/bindings/c/src/write.rs
+++ b/bindings/c/src/write.rs
@@ -167,7 +167,8 @@ pub unsafe extern "C" fn paimon_table_new_write_builder(
}
/// Create a one-shot fixed-bucket WriteBuilder for a postpone table.
-/// A bucket plan must be set before creating a writer.
+/// An explicit bucket plan is optional. Without one, core reuses existing
+/// layouts and uses the configured default or infers new partition counts.
///
/// # Safety
/// `table` must be a valid table pointer, or null (returns error).
@@ -195,7 +196,8 @@ pub unsafe extern "C" fn
paimon_table_new_write_builder_with_commit_user(
}
/// Create a fixed-bucket WriteBuilder with a stable commit identity.
-/// A bucket plan must be set before creating a writer.
+/// Share an explicit bucket plan across distributed writers. Without one, core
+/// reuses existing layouts and infers new counts independently per writer.
///
/// # Safety
/// `table` must be a valid table pointer. `commit_user` must be a valid UTF-8
diff --git a/bindings/go/postpone_fixed_bucket_write.go
b/bindings/go/postpone_fixed_bucket_write.go
index d3445c16..dc279286 100644
--- a/bindings/go/postpone_fixed_bucket_write.go
+++ b/bindings/go/postpone_fixed_bucket_write.go
@@ -29,7 +29,9 @@ import (
)
// PostponeFixedBucketWriteBuilder creates fixed-bucket writers for bucket=-2
-// tables. A resolved bucket plan must be supplied before NewWrite.
+// tables. Without an explicit plan, core reuses known layouts and applies the
+// configured default or infers counts for new partitions across this writer's
+// input batches. Distributed writers should share one resolved bucket plan.
type PostponeFixedBucketWriteBuilder struct {
ctx context.Context
lib *libRef
@@ -151,7 +153,7 @@ func (wb *PostponeFixedBucketWriteBuilder) NewCommit()
(*PostponeFixedBucketTabl
return &PostponeFixedBucketTableCommit{ctx: wb.ctx, lib: wb.lib, inner:
inner}, nil
}
-// PostponeFixedBucketTableWrite writes rows according to a resolved bucket
plan.
+// PostponeFixedBucketTableWrite writes rows using explicit or inferred bucket
counts.
type PostponeFixedBucketTableWrite struct {
ctx context.Context
lib *libRef
diff --git a/bindings/go/tests/paimon_test.go b/bindings/go/tests/paimon_test.go
index e7b0c943..8a359b0c 100644
--- a/bindings/go/tests/paimon_test.go
+++ b/bindings/go/tests/paimon_test.go
@@ -688,9 +688,11 @@ func TestMultiplePostponeFixedBucketWritersSharePlan(t
*testing.T) {
defer builder.Close()
}
- if _, err := builders[0].NewWrite(); err == nil ||
!strings.Contains(err.Error(), "bucket plan is required") {
- t.Fatalf("Expected missing bucket plan error, got: %v", err)
+ automatic, err := builders[0].NewWrite()
+ if err != nil {
+ t.Fatalf("Failed to create automatically planned writer: %v",
err)
}
+ automatic.Close()
plan := makePartitionedBucketPlan(t, []string{"2026-08-14",
"2026-08-15"}, 1)
for index, builder := range builders {
if err := builder.WithBucketPlan(plan); err != nil {
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 5ce313ee..adebdc1e 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -294,11 +294,16 @@ for a partition and bucket must have a single writer
owner. Both duplicate
owners in a commit and stale overlapping writers are rejected. Pending files
(`bucket=-2`) can coexist with real buckets; they remain pending until
assigned.
-Without an explicit plan, set `postpone.default-bucket-num` to a positive
integer.
-The count is used exactly for new partitions, and append reuses existing
layouts
-from the same snapshot used to initialize sequence numbers. Overwrite uses the
-configured default for replaced partitions. No staging, automatic rescaling or
-compaction is performed. Deletion-vector tables are rejected by this writer.
+Without an explicit plan, `postpone.default-bucket-num` can provide an exact
count
+for new partitions. Otherwise core buffers unknown partitions until preparation
+and infers their counts from this writer's input: the target row count takes
+precedence over estimated BinaryRow bytes. Counts round up to a power of two,
+then cap at the configured maximum parallelism. Append reuses existing layouts
+from the same snapshot used to initialize sequence numbers and writes known
+partitions directly. Overwrite plans replaced partitions from the new input.
+Distributed writers should share one resolved plan. This does not implement
+Spark's temporary-file measurement, automatic rescaling or compaction.
+Deletion-vector tables are rejected by this writer.
A failed write cannot be prepared, and close deletes only unprepared output.
Configure overwrite on the batch builder:
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 5842f1fb..badf0a44 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -462,8 +462,10 @@ class BatchTableUpdate:
A Table source reads its selected snapshot or numeric timestamp window
in core, independently of the target. Arrow input preserves batches
and source row order.
- Clauses carry assignments as (column, source/target/literal, value)
- tuples, a delete flag, and optional SQL condition metadata.
+ Clauses carry assignments as (column, source/target/literal/callable,
value)
+ tuples, a delete flag, and optional SQL condition metadata. Callables
+ consume the complete clause's qualified t./s. Arrow columns and return
+ an Array or ChunkedArray with the same row count; empty clauses are
lazy.
Commit returned messages through the matching builder's committer.
"""
...
@@ -520,8 +522,10 @@ class StreamTableUpdate:
A Table source reads its selected snapshot or numeric timestamp window
in core, independently of the target. Arrow input preserves batches
and source row order.
- Clauses carry assignments as (column, source/target/literal, value)
- tuples, a delete flag, and optional SQL condition metadata.
+ Clauses carry assignments as (column, source/target/literal/callable,
value)
+ tuples, a delete flag, and optional SQL condition metadata. Callables
+ consume the complete clause's qualified t./s. Arrow columns and return
+ an Array or ChunkedArray with the same row count; empty clauses are
lazy.
Commit returned messages through the matching builder's committer.
"""
...
@@ -717,7 +721,8 @@ class BatchWriteBuilder:
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.
+ use the schema's Python types or Java textual specs; None or the
default
+ partition name is null.
"""
...
def with_restore_snapshot(self, snapshot_id: int) -> "BatchWriteBuilder":
...
@@ -728,10 +733,15 @@ class BatchWriteBuilder:
class PostponeFixedBucketWriteBuilder:
"""Real-bucket batch writes for a bucket=-2 primary-key table.
- Supply a shared Arrow plan or configure postpone.default-bucket-num.
+ Supply a shared Arrow plan, configure postpone.default-bucket-num, or let
+ core infer new partition counts across all input batches at prepare_commit.
+ Inference uses target rows, otherwise estimated BinaryRow bytes, rounds up
+ to a power of two and caps at the configured maximum parallelism.
The default applies exactly to new partitions; append reuses existing
- layouts from the writer's baseline snapshot. Overwrite uses the new
default.
- Automatic staging, rescaling and compaction are not performed.
+ layouts from the writer's baseline snapshot. Overwrite plans replaced
+ partitions from the new input or configured default.
+ 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_bucket_plan(self, plan: pyarrow.RecordBatch) ->
"PostponeFixedBucketWriteBuilder":
diff --git a/bindings/python/src/merge_into.rs
b/bindings/python/src/merge_into.rs
index 17e8683b..4d237d2f 100644
--- a/bindings/python/src/merge_into.rs
+++ b/bindings/python/src/merge_into.rs
@@ -67,7 +67,12 @@ fn assignments(
let assignment = match kind.as_str() {
"source" =>
MergeAssignment::SourceColumn(value.bind(py).extract()?),
"target" =>
MergeAssignment::TargetColumn(value.bind(py).extract()?),
- "literal" => {
+ "literal" | "callable" => {
+ if kind == "callable" && !value.bind(py).is_callable() {
+ return Err(PyValueError::new_err(
+ "MERGE callable assignment must be callable",
+ ));
+ }
let values = PyDict::new(py);
values.set_item(&name, value)?;
let assignment =
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index f9a1716a..0ad668c5 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -103,29 +103,14 @@ fn boolean_option(table: &Table, key: &str, default:
bool) -> PyResult<bool> {
}
}
-/// Java partition specs may encode numeric values as strings. Other Python
+/// Java partition specs encode schema-typed 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> {
+fn partition_value(value: &Bound<'_, PyAny>, data_type: &DataType) ->
PyResult<Option<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()),
- _ => {}
- }
+ return paimon::table::parse_partition_spec_value(text,
data_type).map_err(to_py_err);
}
- py_to_datum(value, data_type)
+ py_to_datum(value, data_type).map(Some)
}
fn partition_spec(
@@ -154,7 +139,7 @@ fn partition_spec(
let datum = if value.is_none() || is_default {
None
} else {
- Some(partition_value(value, field.data_type())?)
+ partition_value(value, field.data_type())?
};
Ok((key, datum))
})
diff --git a/bindings/python/tests/test_table_commit.py
b/bindings/python/tests/test_table_commit.py
index 2de95d8b..e6ee4bdd 100644
--- a/bindings/python/tests/test_table_commit.py
+++ b/bindings/python/tests/test_table_commit.py
@@ -404,6 +404,29 @@ def
test_partition_truncation_matches_java_lifecycle(tmp_path):
assert _rows(table) == []
[email protected]("overwrite", [False, True])
+def test_decimal_partition_precision_overflow_matches_java_null_spec(tmp_path,
overwrite):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+ ctx.sql("CREATE SCHEMA paimon.db")
+ ctx.sql("""CREATE TABLE paimon.db.t (id INT, pt DECIMAL(3, 0)) PARTITIONED
BY (pt)
+ WITH ('dynamic-partition-overwrite' = 'false')""")
+ ctx.sql("INSERT INTO paimon.db.t (id, pt) VALUES (1, NULL), (2, 12)")
+ table = PaimonCatalog({"warehouse": str(tmp_path)}).get_table("db.t")
+ if overwrite:
+ table.new_batch_write_builder().with_overwrite({"pt":
"1000"}).new_commit().commit([])
+ else:
+
table.new_batch_write_builder().new_commit().truncate_partitions([{"pt":
"1000"}])
+ assert _rows(table) == [2]
+
+
+def test_integer_partition_spec_accepts_java_unicode_digits(tmp_path):
+ table = _table(tmp_path, partitioned=True)
+ _append(table, [1, 2], [12, 13])
+ table.new_batch_write_builder().new_commit().truncate_partitions([{"pt":
"12"}])
+ assert _rows(table) == [2]
+
+
@pytest.mark.parametrize("spec", [{"id": 1}, {"pt": "bad"}, {"pt": True}])
def test_partition_spec_validation_precedes_mutation(tmp_path, spec):
table = _table(tmp_path, partitioned=True)
diff --git a/bindings/python/tests/test_update.py
b/bindings/python/tests/test_update.py
index 68157654..069a3a34 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -508,3 +508,50 @@ def
test_prepared_merge_accepts_native_table_sources(tmp_path, stream, historica
actual = pa.Table.from_batches(context.sql(
'SELECT id, value FROM paimon.table_merge.target ORDER BY
id')).to_pylist()
assert actual == [dict(id=1, value=11), dict(id=2, value=20 if historical
else 22), dict(id=3, value=33)]
+
+
[email protected]('stream', [False, True])
+def
test_prepared_merge_callable_uses_complete_clause_and_preserves_exception(tmp_path,
stream):
+ context = SQLContext()
+ context.register_catalog('paimon', {'warehouse': str(tmp_path)})
+ context.sql('CREATE SCHEMA paimon.callable_merge')
+ context.sql("""CREATE TABLE paimon.callable_merge.t (id INT, value INT)
WITH (
+ 'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')""")
+ for values in ['(1, 10)', '(2, 20)']:
+ context.sql('INSERT INTO paimon.callable_merge.t (id, value) VALUES '
+ values)
+ table = PaimonCatalog({'warehouse':
str(tmp_path)}).get_table('callable_merge.t')
+ builder =
(table.new_stream_write_builder().with_commit_user('callable-merge')
+ if stream else table.new_batch_write_builder())
+ kwargs = dict(commit_identifier=42) if stream else {}
+ update = builder.new_update()
+ source = pa.table({'id': pa.array([1, 2, 3], pa.int32()), 'delta':
pa.array([4, 5, 6], pa.int32())})
+ calls = []
+
+ def values(batch):
+ calls.append(batch.num_rows)
+ assert {'t.id', 't.value', 's.id', 's.delta'} <=
set(batch.column_names)
+ return pa.chunked_array([[100], [200]], type=pa.int32())
+
+ messages = update.merge_into(source, on=[('id', 'id')], when_matched=[
+ dict(delete=False, assignments=[('value', 'callable', values)])],
when_not_matched=[
+ dict(assignments=[('id', 'source', 'id'), ('value', 'callable', lambda
batch: batch['s.delta'])])],
+ **kwargs)
+ assert calls == [2]
+ commit = builder.new_commit()
+ commit.commit(42, messages) if stream else commit.commit(messages)
+ actual = pa.Table.from_batches(context.sql(
+ 'SELECT id, value FROM paimon.callable_merge.t ORDER BY
id')).to_pylist()
+ assert actual == [dict(id=1, value=100), dict(id=2, value=200), dict(id=3,
value=6)]
+ error = RuntimeError('callback error')
+
+ def fail(batch):
+ raise error
+
+ clause = [dict(delete=False, assignments=[('value', 'callable', fail)])]
+ update = builder.new_update()
+ assert update.merge_into(pa.table({'id': pa.array([99], pa.int32())}),
on=[('id', 'id')],
+ when_matched=clause, when_not_matched=[],
**kwargs) == []
+ with pytest.raises(RuntimeError) as caught:
+ update.merge_into(source, on=[('id', 'id')], when_matched=clause,
+ when_not_matched=[], **kwargs)
+ assert caught.value is error
diff --git a/crates/paimon/src/io/file_io/fuse.rs
b/crates/paimon/src/io/file_io/fuse.rs
index 660b0784..d0a287db 100644
--- a/crates/paimon/src/io/file_io/fuse.rs
+++ b/crates/paimon/src/io/file_io/fuse.rs
@@ -96,6 +96,105 @@ mod tests {
base
}
+ #[tokio::test]
+ async fn bound_mount_supports_table_write_commit_and_truncate() {
+ use crate::catalog::Identifier;
+ use crate::spec::{DataType, IntType, Schema, TableSchema};
+ use crate::table::{Table, TableCommit};
+ use arrow_array::{Int32Array, RecordBatch};
+ use futures::TryStreamExt;
+
+ let mount = tempfile::tempdir().unwrap();
+ let root = "oss://bucket/warehouse/table";
+ let io = FileIOBuilder::new("memory")
+ .build()
+ .unwrap()
+ .with_fuse(root, mount.path().to_str().unwrap())
+ .unwrap();
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap();
+ let table = Table::new(
+ io.clone(),
+ Identifier::new("db", "table"),
+ root.into(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ // Construction binds schema and data I/O to the same provider.
+ let schema_path = format!("{root}/schema/schema-0");
+ io.new_output(&schema_path)
+ .unwrap()
+ .write(bytes::Bytes::from(
+ serde_json::to_vec(table.schema()).unwrap(),
+ ))
+ .await
+ .unwrap();
+ assert_eq!(table.schema_manager().schema(0).await.unwrap().id(), 0);
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter(vec![(
+ "id",
+ Arc::new(Int32Array::from(vec![1, 2])) as arrow_array::ArrayRef,
+ )])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let files = messages
+ .iter()
+ .flat_map(|m| {
+ m.new_files
+ .iter()
+ .map(move |f| format!("{root}/bucket-{}/{}", m.bucket,
f.file_name))
+ })
+ .collect::<Vec<_>>();
+ let committer = TableCommit::new(table.clone(),
builder.commit_user().into());
+ committer.commit_with_identifier(messages, 1).await.unwrap();
+ writer.close().await;
+ assert!(mount.path().join("snapshot/snapshot-1").exists());
+ for file in &files {
+ assert!(io.exists(file).await.unwrap());
+ }
+ let reader = table.new_read_builder();
+ let plan = reader.new_scan().plan().await.unwrap();
+ let mut batches =
reader.new_read().unwrap().to_arrow(plan.splits()).unwrap();
+ let mut count = 0;
+ while let Some(batch) = batches.try_next().await.unwrap() {
+ count += batch.num_rows();
+ }
+ assert_eq!(count, 2);
+ committer.truncate_table_with_identifier(17).await.unwrap();
+ assert!(table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+ // Logical truncation must retain files referenced by older snapshots.
+ for file in &files {
+ assert!(io.exists(file).await.unwrap());
+ }
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ committer
+ .commit_with_identifier(writer.prepare_commit().await.unwrap(), 18)
+ .await
+ .unwrap();
+ // Retrying an uncertain truncate cannot remove the later append.
+ committer.truncate_table_with_identifier(17).await.unwrap();
+ assert!(!table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+ }
+
#[tokio::test]
async fn
mounted_cache_reuses_files_isolates_mounts_and_invalidates_writes() {
let first = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index cba2dab6..c9850975 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1618,6 +1618,49 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(DEFAULT_TARGET_FILE_SIZE)
}
+ /// Maximum automatically inferred postpone bucket count (Java default:
2048).
+ pub fn postpone_batch_write_fixed_bucket_max_parallelism(&self) ->
crate::Result<i32> {
+ let key = "postpone.batch-write-fixed-bucket.max-parallelism";
+ let Some(raw) = self.options.get(key) else {
+ return Ok(2048);
+ };
+ raw.parse::<i32>()
+ .ok()
+ .filter(|n| *n > 0)
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!("{key} must be a positive 32-bit integer, got
'{raw}'"),
+ })
+ }
+
+ pub fn postpone_target_row_num_per_bucket(&self) ->
crate::Result<Option<i64>> {
+ self.options
+ .get("postpone.target-row-num-per-bucket")
+ .map(|raw| {
+ raw.parse::<i64>().ok().filter(|n| *n > 0).ok_or_else(|| {
+ crate::Error::ConfigInvalid {
+ message: format!(
+ "postpone.target-row-num-per-bucket must be
positive, got '{raw}'"
+ ),
+ }
+ })
+ })
+ .transpose()
+ }
+
+ pub fn postpone_target_size_per_bucket(&self) -> crate::Result<i64> {
+ self.options
+ .get("postpone.target-size-per-bucket")
+ .map_or(Ok(1024 * 1024 * 1024), |raw| {
+ parse_memory_size(raw).filter(|n| *n > 0).ok_or_else(|| {
+ crate::Error::ConfigInvalid {
+ message: format!(
+ "postpone.target-size-per-bucket must be positive,
got '{raw}'"
+ ),
+ }
+ })
+ })
+ }
+
/// Maximum rows in a newly written data file. Java defaults to
`Long.MAX_VALUE`.
pub fn target_file_row_num(&self) -> crate::Result<i64> {
let Some(raw) = self.options.get("target-file-row-num") else {
diff --git a/crates/paimon/src/table/branch_write_tests.rs
b/crates/paimon/src/table/branch_write_tests.rs
index 612df694..c1538848 100644
--- a/crates/paimon/src/table/branch_write_tests.rs
+++ b/crates/paimon/src/table/branch_write_tests.rs
@@ -563,6 +563,123 @@ async fn
branch_write_schema_id_is_resolved_in_its_own_namespace() {
);
}
+#[tokio::test]
+async fn rest_branch_copy_retains_bound_file_io_and_loads_branch_identity() {
+ use axum::{extract::Path, routing::get, Json, Router};
+
+ let mount = tempfile::tempdir().unwrap();
+ let location = "oss://bucket/warehouse/db/t";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("query-auth.enabled", "true")
+ .build()
+ .unwrap();
+ let response = move |name: &str| {
+ serde_json::json!({
+ "database": "db", "name": name, "id": format!("uuid-{name}"),
+ "path": location, "isExternal": false,
+ "schemaId": if name == "t" { 0 } else { 1 }, "schema": schema
+ })
+ };
+ let original = response("t");
+ let app = Router::new().route(
+ "/v1/databases/:db/tables/:table",
+ get(move |Path((_db, name)): Path<(String, String)>| {
+ let body = response(&name);
+ async move { Json(body) }
+ }),
+ );
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
+ let mut options = crate::Options::new();
+ for (key, value) in [
+ ("uri", format!("http://{address}")),
+ ("warehouse", "test".into()),
+ ("token.provider", "bear".into()),
+ ("token", "test-token".into()),
+ ("fuse.enabled", "true".into()),
+ ("fuse.mode", "pvfs".into()),
+ ("fuse.root", mount.path().to_string_lossy().into_owned()),
+ ("fuse.validation-mode", "none".into()),
+ ] {
+ options.set(key, value);
+ }
+ let main = Table::from_rest_response(
+ Identifier::new("db", "t"),
+ serde_json::from_value(original).unwrap(),
+ options,
+ )
+ .await
+ .unwrap();
+ let branch_schema = TableSchema::new(
+ 1,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("query-auth.enabled", "true")
+ .build()
+ .unwrap(),
+ );
+ let schema_path = format!("{location}/branch/branch-dev/schema/schema-1");
+ main.file_io()
+ .new_output(&schema_path)
+ .unwrap()
+ .write(bytes::Bytes::from(
+ serde_json::to_vec(&branch_schema).unwrap(),
+ ))
+ .await
+ .unwrap();
+ // The Python binding's existing Table.copy delegates to this core API.
+ let copied = main
+ .copy_with_time_travel_strict(HashMap::from([
+ ("branch".into(), "dev".into()),
+ ("read.batch-size".into(), "17".into()),
+ ]))
+ .await
+ .unwrap();
+ assert_eq!(copied.branch(), "dev");
+ assert_eq!(copied.schema().id(), 1);
+ assert_eq!(
+ copied.rest_env().unwrap().identifier().object(),
+ "t$branch_dev"
+ );
+ assert_eq!(copied.rest_env().unwrap().uuid(), "uuid-t$branch_dev");
+ assert!(copied.query_auth_session.is_some());
+ assert_ne!(copied.query_auth_session, main.query_auth_session);
+ assert_eq!(copied.schema().options()["read.batch-size"], "17");
+ assert_eq!(
+ copied.schema_manager().schema(1).await.unwrap().as_ref(),
+ &branch_schema
+ );
+ assert!(!mount
+ .path()
+ .join("db/t$branch_dev/branch/branch-dev/schema/schema-1")
+ .exists());
+ let main_schema_path = format!("{location}/schema/schema-0");
+ main.file_io()
+ .new_output(&main_schema_path)
+ .unwrap()
+ .write(bytes::Bytes::from(
+ serde_json::to_vec(main.schema()).unwrap(),
+ ))
+ .await
+ .unwrap();
+ let restored = copied
+ .copy_with_time_travel_strict(HashMap::from([("branch".into(),
"main".into())]))
+ .await
+ .unwrap();
+ assert_eq!(restored.branch(), "main");
+ assert_eq!(restored.schema().id(), 0);
+ assert_eq!(restored.schema().options()["read.batch-size"], "17");
+ assert_eq!(restored.rest_env().unwrap().uuid(), "uuid-t");
+ assert_ne!(restored.query_auth_session, copied.query_auth_session);
+ assert_eq!(
+ restored.schema_manager().schema(0).await.unwrap().as_ref(),
+ main.schema()
+ );
+ server.abort();
+}
+
#[tokio::test]
async fn
branch_write_rest_publisher_routes_by_argument_and_preserves_payload() {
use crate::api::rest_api::RESTApi;
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 19b9315d..42d1a16b 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -103,6 +103,7 @@ mod mosaic_table_write_tests;
mod object_table;
mod partition_filter;
mod partition_row_count;
+mod partition_spec;
mod partition_stat;
#[cfg(feature = "fulltext")]
mod pk_full_text_bucket_search;
@@ -123,6 +124,7 @@ mod pk_vector_read;
mod pk_vector_scan;
mod pk_vector_search_params;
mod postpone_bucket_plan;
+mod postpone_bucket_planner;
mod postpone_file_writer;
mod postpone_fixed_bucket_router;
mod postpone_fixed_bucket_write;
@@ -221,6 +223,7 @@ pub use incremental_scan::{
pub use instant::Instant;
pub use object_table::{ObjectEntry, ObjectTable};
pub use partition_row_count::PartitionRowCount;
+pub use partition_spec::parse_partition_spec_value;
pub use partition_stat::PartitionStat;
pub use pk_vector_bucket_split::{BucketVectorPayload, BucketVectorSearchSplit};
pub use postpone_bucket_plan::{PostponeBucketPlan,
POSTPONE_BUCKET_PLAN_TOTAL_BUCKETS_FIELD};
@@ -867,6 +870,8 @@ impl Table {
/// table's fields and keys come from that snapshot's schema while the
/// runtime options stay merged. Column declarations follow the historical
/// schema unless explicitly overridden, as in Java.
+ /// A different `branch` option first loads that branch's schema and
catalog
+ /// identity, retaining the bound FileIO and preceding dynamic overrides.
/// Like Java, resolution failures fall back silently to the current
/// schema (the `if let Ok` below swallows them); an invalid selector
/// still fails later at scan planning.
@@ -947,7 +952,28 @@ impl Table {
CoreOptions::new(self.schema.options())
.ensure_type_paimon_served(&self.identifier.full_name())?;
self.ensure_format_table_partition_options_unchanged(&extra)?;
- let mut table = self.copy_with_options(extra);
+ // Resolve a different branch before applying dynamic read options so
+ // its schema, REST identity and metadata namespace stay consistent.
+ let mut table = match extra.get("branch") {
+ Some(branch) if branch != self.branch() => {
+ let target = self.copy_with_branch(branch).await?;
+ // Earlier copy() overrides remain in effect on the new branch;
+ // other defaults come from its own schema.
+ let mut dynamic: HashMap<_, _> = self
+ .applied_dynamic_option_keys
+ .iter()
+ .filter_map(|key| {
+ self.schema
+ .options()
+ .get(key)
+ .map(|value| (key.clone(), value.clone()))
+ })
+ .collect();
+ dynamic.extend(extra);
+ target.copy_with_options(dynamic)
+ }
+ _ => self.copy_with_options(extra),
+ };
// Reject unimplemented scan options on the merged view before any IO,
so
// both table-level and per-read options are covered.
CoreOptions::new(table.schema().options()).validate_scan_options()?;
@@ -1028,7 +1054,8 @@ impl Table {
};
if let Some(env) = &self.rest_env {
// Catalog branches may have no schema directory in the data store.
- // Load their schema, identity and FileIO through the same catalog.
+ // Load their schema and identity through the same catalog. A table
+ // copy retains the original FileIO, including its bound FUSE
mount.
let identifier = env.identifier().with_branch(&branch)?;
let (current, mut table) = tokio::try_join!(
env.current_table_identity_checked(),
@@ -1045,6 +1072,13 @@ impl Table {
source: None,
});
}
+ table.file_io = self.file_io.clone();
+ let schema_manager = SchemaManager::new(table.file_io.clone(),
table.location.clone());
+ table.schema_manager = if branch == DEFAULT_MAIN_BRANCH {
+ schema_manager
+ } else {
+ schema_manager.with_branch(&branch)
+ };
let mut options = table.schema.options().clone();
options.insert("branch".to_string(), branch);
table.schema = table.schema.copy_with_replaced_options(options);
diff --git a/crates/paimon/src/table/partition_spec.rs
b/crates/paimon/src/table/partition_spec.rs
new file mode 100644
index 00000000..1d1c8615
--- /dev/null
+++ b/crates/paimon/src/table/partition_spec.rs
@@ -0,0 +1,284 @@
+// 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.
+
+//! Java TypeUtils conversion for textual overwrite and truncate partition
specs.
+
+use crate::spec::{DataType, Datum};
+use arrow_array::{ArrayRef, RecordBatch, StringArray};
+use arrow_schema::{Field, Schema};
+use std::sync::Arc;
+
+/// Convert a Java partition spec, retaining DECIMAL precision overflow as
NULL.
+pub fn parse_partition_spec_value(
+ text: &str,
+ data_type: &DataType,
+) -> crate::Result<Option<Datum>> {
+ let invalid = || crate::Error::DataInvalid {
+ message: format!("Invalid partition value '{text}' for {data_type:?}"),
+ source: None,
+ };
+ match data_type {
+ // TypeUtils uses valueOf, not the decimal-truncating SQL cast rules.
+ DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) |
DataType::BigInt(_) => {
+ let text = if text.is_ascii() {
+ std::borrow::Cow::Borrowed(text)
+ } else {
+ // Integer.valueOf scans Character.digit, unlike SQL's
+ // BinaryStringUtils integer cast with its fractional grammar.
+ std::borrow::Cow::Owned(
+ text.chars()
+ .map(|ch| {
+ crate::common::number::java_decimal_digit(ch)
+ .map_or(ch, |digit| char::from(b'0' + digit))
+ })
+ .collect::<String>(),
+ )
+ };
+ return match data_type {
+ DataType::TinyInt(_) => text.parse().map(Datum::TinyInt),
+ DataType::SmallInt(_) => text.parse().map(Datum::SmallInt),
+ DataType::Int(_) => text.parse().map(Datum::Int),
+ DataType::BigInt(_) => text.parse().map(Datum::Long),
+ _ => unreachable!(),
+ }
+ .map(Some)
+ .map_err(|_| invalid());
+ }
+ DataType::Float(_) => {
+ use crate::common::number::{format_java_float, parse_java_float};
+ let double = parse_java_float(text, false)?;
+ let single = parse_java_float(text, true)? as f32;
+ return if double == f64::from(single)
+ || format_java_float(single) == format_java_float(double)
+ {
+ Ok(Some(Datum::Float(single)))
+ } else {
+ Err(invalid())
+ };
+ }
+ DataType::Double(_) => {
+ return crate::common::number::parse_java_float(text, false)
+ .map(Datum::Double)
+ .map(Some)
+ }
+ DataType::Time(_) => return
parse_time_spec(text).map(Datum::Time).map(Some),
+ DataType::Char(ty) if text.chars().count() > ty.length() => return
Err(invalid()),
+ DataType::VarChar(ty) if text.chars().count() > ty.length() as usize
=> {
+ return Err(invalid())
+ }
+ DataType::Char(_) | DataType::VarChar(_) => return
Ok(Some(Datum::String(text.into()))),
+ DataType::Binary(_) => return
Ok(Some(Datum::Bytes(text.as_bytes().to_vec()))),
+ DataType::VarBinary(ty) => {
+ return if text.len() <= ty.length() as usize {
+ Ok(Some(Datum::Bytes(text.as_bytes().to_vec())))
+ } else {
+ Err(invalid())
+ };
+ }
+ DataType::Boolean(_)
+ | DataType::Date(_)
+ | DataType::Timestamp(_)
+ | DataType::LocalZonedTimestamp(_)
+ | DataType::Decimal(_) => {}
+ _ => return Err(invalid()),
+ }
+ let input: ArrayRef = Arc::new(StringArray::from(vec![text]));
+ let array = crate::arrow::cast_array_for_schema_evolution(
+ &input,
+ &DataType::VarChar(crate::spec::VarCharType::string_type()),
+ data_type,
+ )?;
+ let batch = RecordBatch::try_new(
+ Arc::new(Schema::new(vec![Field::new(
+ "partition",
+ array.data_type().clone(),
+ true,
+ )])),
+ vec![array],
+ )
+ .map_err(|_| invalid())?;
+ crate::spec::extract_datum_from_arrow(&batch, 0, 0, data_type)
+}
+
+fn parse_time_spec(text: &str) -> crate::Result<i32> {
+ let invalid = || crate::Error::DataInvalid {
+ message: format!("Invalid TIME partition value '{text}'"),
+ source: None,
+ };
+ // BinaryStringUtils.toTime accepts the legacy millis representation.
+ let digits = text.strip_prefix('-').unwrap_or(text);
+ if !digits.is_empty() && digits.bytes().all(|c| c.is_ascii_digit()) {
+ return text.parse().map_err(|_| invalid());
+ }
+ // DateTimeUtils.parseTime accepts partial clocks and adds the signed zone
+ // offset, without restricting clock components to a single day's range.
+ let component = |part: &str| -> crate::Result<i32> {
+ let part = part.trim_matches(|c| c <= ' ');
+ if part.is_empty() || !part.bytes().all(|c| c.is_ascii_digit()) {
+ return Err(invalid());
+ }
+ part.parse().map_err(|_| invalid())
+ };
+ let zone_at = text.find('-').or_else(|| text.find('+'));
+ let (clock, offset) = match zone_at {
+ Some(at) => {
+ let sign: i32 = if text.as_bytes()[at] == b'-' { -1 } else { 1 };
+ let (hours, minutes) = text[at + 1..]
+ .split_once(':')
+ .unwrap_or((&text[at + 1..], "0"));
+ let millis = component(hours)?
+ .wrapping_mul(3_600_000)
+ .wrapping_add(component(minutes)?.wrapping_mul(60_000));
+ (&text[..at], sign.wrapping_mul(millis))
+ }
+ None => (text, 0),
+ };
+ let parts = clock.split(':').collect::<Vec<_>>();
+ if parts.len() > 3 || parts.len() < 3 && zone_at.is_some_and(|at| text[at
+ 1..].contains(':'))
+ {
+ // Java searches for the clock's colons in the complete string. With
+ // missing seconds a colon in the offset is not a valid second colon.
+ return Err(invalid());
+ }
+ let hours = component(parts[0])?;
+ let minutes = parts.get(1).map(|v| component(v)).transpose()?.unwrap_or(0);
+ let (seconds, fraction) = match parts.get(2) {
+ Some(value) => {
+ let (seconds, fraction) = value.split_once('.').unwrap_or((value,
""));
+ (component(seconds)?, fraction.trim_matches(|c| c <= ' '))
+ }
+ None => (0, ""),
+ };
+ // Java's fraction parser rounds using the fourth character.
+ let mut millis = 0;
+ let mut chars = fraction.chars();
+ for multiplier in [100, 10, 1] {
+ let Some(ch) = chars.next() else { break };
+ millis += ch.to_digit(10).filter(|_| ch.is_ascii()).unwrap_or(0) as
i32 * multiplier;
+ }
+ if chars.next().is_some_and(|ch| ch >= '5') {
+ millis += 1;
+ }
+ Ok(hours
+ .wrapping_mul(3_600_000)
+ .wrapping_add(minutes.wrapping_mul(60_000))
+ .wrapping_add(seconds.wrapping_mul(1000))
+ .wrapping_add(millis)
+ .wrapping_add(offset))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{
+ BooleanType, DateType, DecimalType, FloatType, IntType, TimeType,
TimestampType,
+ };
+
+ #[test]
+ fn partition_specs_accept_java_unicode_and_decimal_null() {
+ let integer = DataType::Int(IntType::new());
+ for (value, expected) in [("12", 12), ("١٢", 12), ("-12", -12),
("+١٢", 12)] {
+ assert_eq!(
+ parse_partition_spec_value(value, &integer).unwrap(),
+ Some(Datum::Int(expected))
+ );
+ }
+ let decimal = DataType::Decimal(DecimalType::new(3, 0).unwrap());
+ assert_eq!(parse_partition_spec_value("1000", &decimal).unwrap(),
None);
+ assert_eq!(parse_partition_spec_value("999.5", &decimal).unwrap(),
None);
+ assert!(parse_partition_spec_value("not a decimal",
&decimal).is_err());
+ let time = DataType::Time(TimeType::new(3).unwrap());
+ for value in ["12:34+02:30", "12+02:30"] {
+ assert!(parse_partition_spec_value(value, &time).is_err());
+ }
+ }
+
+ #[test]
+ fn partition_specs_use_java_typed_text_conversion() {
+ assert_eq!(
+ parse_partition_spec_value("TRUE",
&DataType::Boolean(BooleanType::new()))
+ .unwrap()
+ .unwrap(),
+ Datum::Bool(true)
+ );
+ assert_eq!(
+ parse_partition_spec_value("1970-01-02",
&DataType::Date(DateType::new()))
+ .unwrap()
+ .unwrap(),
+ Datum::Date(1)
+ );
+ assert_eq!(
+ parse_partition_spec_value("12:34:56.789",
&DataType::Time(TimeType::new(3).unwrap()))
+ .unwrap()
+ .unwrap(),
+ Datum::Time(45_296_789)
+ );
+ assert_eq!(
+ parse_partition_spec_value(
+ "1970-01-01 00:00:00.123456",
+ &DataType::Timestamp(TimestampType::new(1).unwrap())
+ )
+ .unwrap()
+ .unwrap(),
+ Datum::Timestamp {
+ millis: 100,
+ nanos: 0
+ }
+ );
+ assert!(matches!(
+ parse_partition_spec_value(
+ "12.345",
+ &DataType::Decimal(DecimalType::new(6, 2).unwrap())
+ )
+ .unwrap()
+ .unwrap(),
+ Datum::Decimal { unscaled: 1235, .. }
+ ));
+ for text in ["42.9", "2147483648", " 42"] {
+ assert!(parse_partition_spec_value(text,
&DataType::Int(IntType::new())).is_err());
+ }
+ let float = DataType::Float(FloatType::new());
+ assert_eq!(
+ parse_partition_spec_value("1.2", &float).unwrap().unwrap(),
+ Datum::Float(1.2)
+ );
+ assert_eq!(
+ parse_partition_spec_value("0x1p2", &float)
+ .unwrap()
+ .unwrap(),
+ Datum::Float(4.0)
+ );
+ assert!(parse_partition_spec_value("1.23456789", &float).is_err());
+ let time = DataType::Time(TimeType::new(0).unwrap());
+ for (text, millis) in [
+ ("45296789", 45_296_789),
+ ("-1", -1),
+ ("12:34", 45_240_000),
+ ("12:34:56.7896+02:30", 54_296_790),
+ ("12:34:56.7896-02:30", 36_296_790),
+ ("24:00:00", 86_400_000),
+ ] {
+ assert_eq!(
+ parse_partition_spec_value(text, &time).unwrap().unwrap(),
+ Datum::Time(millis)
+ );
+ }
+ for text in ["+1", "12:bad", "12:34:56:78", "2147483648:00"] {
+ assert!(parse_partition_spec_value(text, &time).is_err());
+ }
+ }
+}
diff --git a/crates/paimon/src/table/postpone_bucket_plan.rs
b/crates/paimon/src/table/postpone_bucket_plan.rs
index f17c6db6..83bba51f 100644
--- a/crates/paimon/src/table/postpone_bucket_plan.rs
+++ b/crates/paimon/src/table/postpone_bucket_plan.rs
@@ -38,6 +38,12 @@ pub struct PostponeBucketPlan {
}
impl PostponeBucketPlan {
+ pub(super) fn from_counts(bucket_counts: HashMap<Vec<u8>, i32>) -> Self {
+ Self {
+ bucket_counts: Arc::new(bucket_counts),
+ default_bucket_num: None,
+ }
+ }
pub fn from_arrow(table: &Table, batch: &RecordBatch) -> Result<Self> {
let partition_fields = table.schema().partition_fields();
let partition_count = partition_fields.len();
diff --git a/crates/paimon/src/table/postpone_bucket_planner.rs
b/crates/paimon/src/table/postpone_bucket_planner.rs
new file mode 100644
index 00000000..5efac025
--- /dev/null
+++ b/crates/paimon/src/table/postpone_bucket_planner.rs
@@ -0,0 +1,437 @@
+// 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.
+
+//! Snapshot-bound sizing for fixed-bucket batch writes.
+
+use super::postpone_bucket_plan::data_invalid;
+use crate::spec::{batch_to_serialized_bytes, BinaryRow, CoreOptions,
DataField, DataType};
+use crate::table::{PostponeBucketPlan, Table, TableScan};
+use crate::Result;
+use arrow_array::{
+ Array, ArrayRef, FixedSizeListArray, LargeListArray, ListArray, MapArray,
RecordBatch,
+ StructArray,
+};
+use arrow_schema::{DataType as ArrowType, Schema};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+pub(super) struct PostponeBucketPlanner {
+ fields: Vec<DataField>,
+ partitions: Vec<usize>,
+ known: HashMap<Vec<u8>, i32>,
+ input: HashMap<Vec<u8>, (u128, u128)>,
+ target_rows: Option<u128>,
+ target_size: u128,
+ maximum: u128,
+ batches: Vec<RecordBatch>,
+}
+
+impl PostponeBucketPlanner {
+ pub(super) fn new(table: &Table) -> Result<Self> {
+ let options = CoreOptions::new(table.schema().options());
+ let target_rows = options
+ .postpone_target_row_num_per_bucket()?
+ .map(|n| n as u128);
+ Ok(Self {
+ fields: table.schema().fields().to_vec(),
+ partitions: table
+ .schema()
+ .partition_keys()
+ .iter()
+ .map(|name| {
+ table
+ .schema()
+ .fields()
+ .iter()
+ .position(|f| f.name() == name)
+ .unwrap()
+ })
+ .collect(),
+ known: HashMap::new(),
+ input: HashMap::new(),
+ target_rows,
+ // Java ignores byte sizing when a row target is supplied.
+ target_size: if target_rows.is_none() {
+ options.postpone_target_size_per_bucket()? as u128
+ } else {
+ 1
+ },
+ maximum:
options.postpone_batch_write_fixed_bucket_max_parallelism()? as u128,
+ batches: Vec::new(),
+ })
+ }
+
+ pub(super) async fn load_metadata(&mut self, table: &Table, snapshot_id:
i64) -> Result<()> {
+ if snapshot_id == 0 {
+ return Ok(());
+ }
+ let snapshot =
table.snapshot_manager().get_snapshot(snapshot_id).await?;
+ let entries = TableScan::new(table, None, vec![], None, None, None)
+ .with_scan_all_files()
+ .plan_manifest_entries(&snapshot)
+ .await?;
+ for entry in entries {
+ let partition = entry.partition().to_vec();
+ if entry.bucket() >= 0 {
+ let count = entry.total_buckets();
+ if count <= 0 || entry.bucket() >= count {
+ return Err(data_invalid(
+ "Invalid total bucket count in existing postpone
files",
+ ));
+ }
+ if self
+ .known
+ .insert(partition, count)
+ .is_some_and(|previous| previous != count)
+ {
+ return Err(data_invalid("Existing postpone files contain
conflicting total bucket counts for one partition"));
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub(super) fn known_plan(&self) -> PostponeBucketPlan {
+ PostponeBucketPlan::from_counts(self.known.clone())
+ }
+
+ pub(super) fn add(&mut self, batch: RecordBatch) ->
Result<Option<RecordBatch>> {
+ let partitions = batch_to_serialized_bytes(&batch, &self.partitions,
&self.fields)?;
+ let mut known_rows = Vec::new();
+ let mut unknown_rows = Vec::new();
+ for (row, partition) in partitions.into_iter().enumerate() {
+ if self.known.contains_key(&partition) {
+ known_rows.push(row);
+ continue;
+ }
+ unknown_rows.push(row);
+ let size = if self.target_rows.is_none() {
+ row_size(&batch, row, &self.fields)?
+ } else {
+ 0
+ };
+ let stats = self.input.entry(partition).or_default();
+ stats.0 += 1;
+ stats.1 += size as u128;
+ }
+ if unknown_rows.is_empty() {
+ return Ok(Some(batch));
+ }
+ if known_rows.is_empty() {
+ self.batches.push(batch);
+ return Ok(None);
+ }
+ let known = super::table_write::take_rows(&batch, &known_rows)?;
+ self.batches
+ .push(super::table_write::take_rows(&batch, &unknown_rows)?);
+ Ok(Some(known))
+ }
+
+ pub(super) fn finish(mut self) -> (PostponeBucketPlan, Vec<RecordBatch>) {
+ for (partition, (rows, size)) in self.input {
+ let required = match self.target_rows {
+ Some(target) => rows.div_ceil(target),
+ None => size.div_ceil(self.target_size),
+ };
+ // PostponeUtils rounds inferred counts up before applying the cap.
+ let count = required.max(1).next_power_of_two().min(self.maximum)
as i32;
+ self.known.insert(partition, count);
+ }
+ (PostponeBucketPlan::from_counts(self.known), self.batches)
+ }
+}
+
+fn round_word(size: usize) -> usize {
+ size.div_ceil(8) * 8
+}
+
+fn row_size(batch: &RecordBatch, row: usize, fields: &[DataField]) ->
Result<usize> {
+ let mut size = BinaryRow::cal_fix_part_size_in_bytes(fields.len() as i32)
as usize;
+ for (column, field) in batch.columns().iter().zip(fields) {
+ size += variable_size(column, row, field.data_type())?;
+ }
+ Ok(size)
+}
+
+fn binary_length(array: &ArrayRef, row: usize) -> Result<usize> {
+ macro_rules! length {
+ ($ty:ty) => {
+ if let Some(values) = array.as_any().downcast_ref::<$ty>() {
+ return Ok(values.value(row).len());
+ }
+ };
+ }
+ length!(arrow_array::BinaryArray);
+ length!(arrow_array::LargeBinaryArray);
+ length!(arrow_array::FixedSizeBinaryArray);
+ length!(arrow_array::StringArray);
+ length!(arrow_array::LargeStringArray);
+ length!(arrow_array::StringViewArray);
+ Err(data_invalid(
+ "Expected binary or string input while sizing postpone data",
+ ))
+}
+
+fn array_width(data_type: &DataType) -> usize {
+ match data_type {
+ DataType::Boolean(_) | DataType::TinyInt(_) => 1,
+ DataType::SmallInt(_) => 2,
+ DataType::Int(_) | DataType::Float(_) | DataType::Date(_) |
DataType::Time(_) => 4,
+ _ => 8,
+ }
+}
+
+fn array_size(values: &ArrayRef, data_type: &DataType) -> Result<usize> {
+ let count = values.len();
+ let mut size = round_word(4 + count.div_ceil(32) * 4 + count *
array_width(data_type));
+ for row in 0..count {
+ if !values.is_null(row) {
+ size += variable_size(values, row, data_type)?;
+ }
+ }
+ Ok(size)
+}
+
+fn variable_size(array: &ArrayRef, row: usize, data_type: &DataType) ->
Result<usize> {
+ // Decimal and non-compact timestamp writers reserve their variable slot
+ // even for NULL, as Java BinaryRowWriter does.
+ if let DataType::Decimal(decimal) = data_type {
+ return Ok(if decimal.precision() > 18 { 16 } else { 0 });
+ }
+ if let DataType::Timestamp(timestamp) = data_type {
+ return Ok(if timestamp.precision() > 3 { 8 } else { 0 });
+ }
+ if let DataType::LocalZonedTimestamp(timestamp) = data_type {
+ return Ok(if timestamp.precision() > 3 { 8 } else { 0 });
+ }
+ if array.is_null(row) {
+ return Ok(0);
+ }
+ match data_type {
+ DataType::Blob(_)
+ | DataType::Binary(_)
+ | DataType::VarBinary(_)
+ | DataType::Char(_)
+ | DataType::VarChar(_) => {
+ let length = binary_length(array, row)?;
+ Ok(if length <= 7 { 0 } else { round_word(length) })
+ }
+ DataType::Row(row_type) => {
+ let values = array
+ .as_any()
+ .downcast_ref::<StructArray>()
+ .ok_or_else(|| data_invalid("Expected ROW input while sizing
postpone data"))?;
+ let mut size =
+ BinaryRow::cal_fix_part_size_in_bytes(row_type.fields().len()
as i32) as usize;
+ for (value, field) in
values.columns().iter().zip(row_type.fields()) {
+ size += variable_size(value, row, field.data_type())?;
+ }
+ Ok(round_word(size))
+ }
+ DataType::Array(element) => {
+ let values = if let Some(values) =
array.as_any().downcast_ref::<ListArray>() {
+ values.value(row)
+ } else if let Some(values) =
array.as_any().downcast_ref::<LargeListArray>() {
+ values.value(row)
+ } else if let Some(values) =
array.as_any().downcast_ref::<FixedSizeListArray>() {
+ values.value(row)
+ } else {
+ return Err(data_invalid(
+ "Expected ARRAY input while sizing postpone data",
+ ));
+ };
+ Ok(round_word(array_size(&values, element.element_type())?))
+ }
+ DataType::Map(map) => map_size(array, row, map.key_type(),
map.value_type()),
+ DataType::Multiset(multiset) => map_size(
+ array,
+ row,
+ multiset.element_type(),
+ &DataType::Int(crate::spec::IntType::new()),
+ ),
+ DataType::Vector(vector) => {
+ let values = array
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .ok_or_else(|| data_invalid("Expected VECTOR input while
sizing postpone data"))?;
+ Ok(round_word(
+ 4 + values.value(row).len() *
array_width(vector.element_type()),
+ ))
+ }
+ _ => {
+ // Reuse the existing Java BinaryRow encoder for scalar layouts,
+ // including inline strings/BINARY and Variant serialization.
+ if matches!(
+ array.data_type(),
+ ArrowType::Boolean
+ | ArrowType::Int8
+ | ArrowType::Int16
+ | ArrowType::Int32
+ | ArrowType::Int64
+ | ArrowType::Float32
+ | ArrowType::Float64
+ | ArrowType::Date32
+ | ArrowType::Time32(_)
+ ) {
+ return Ok(0);
+ }
+ let field = DataField::new(0, "value".into(), data_type.clone());
+ let batch = RecordBatch::try_new(
+ Arc::new(Schema::new(vec![arrow_schema::Field::new(
+ "value",
+ array.data_type().clone(),
+ true,
+ )])),
+ vec![array.slice(row, 1)],
+ )
+ .map_err(|e| data_invalid(e.to_string()))?;
+ let encoded = batch_to_serialized_bytes(&batch, &[0], &[field])?;
+ Ok(encoded[0].len() - 4 - BinaryRow::cal_fix_part_size_in_bytes(1)
as usize)
+ }
+ }
+}
+
+fn map_size(array: &ArrayRef, row: usize, key: &DataType, value: &DataType) ->
Result<usize> {
+ let entries = array
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .ok_or_else(|| data_invalid("Expected MAP input while sizing postpone
data"))?
+ .value(row);
+ Ok(round_word(
+ 4 + array_size(entries.column(0), key)? +
array_size(entries.column(1), value)?,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{BlobType, DecimalType};
+ use crate::table::table_write::tests::{
+ make_batch, setup_dirs, test_file_io, test_postpone_pk_table,
+ };
+ use arrow_array::{Decimal128Array, LargeBinaryArray};
+
+ #[test]
+ fn binary_row_sizing_handles_blob_and_null_array_values() {
+ let blob: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+ Some(b"123456789".as_slice()),
+ None,
+ ]));
+ let blob_type = DataType::Blob(BlobType::new());
+ assert_eq!(variable_size(&blob, 0, &blob_type).unwrap(), 16);
+ assert_eq!(variable_size(&blob, 1, &blob_type).unwrap(), 0);
+ let decimal = DataType::Decimal(DecimalType::new(38, 0).unwrap());
+ let values: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![None])
+ .with_precision_and_scale(38, 0)
+ .unwrap(),
+ );
+ assert_eq!(variable_size(&values, 0, &decimal).unwrap(), 16);
+ assert_eq!(array_size(&values, &decimal).unwrap(), 16);
+ let values: ArrayRef = Arc::new(
+ Decimal128Array::from(vec![Some(123i128)])
+ .with_precision_and_scale(38, 0)
+ .unwrap(),
+ );
+ assert_eq!(array_size(&values, &decimal).unwrap(), 32);
+ }
+
+ #[tokio::test]
+ async fn
inference_excludes_old_pending_files_and_streams_known_partitions() {
+ let io = test_file_io();
+ let path = "memory:/auto_postpone_pending";
+ setup_dirs(&io, path).await;
+ let table = test_postpone_pk_table(&io,
path).copy_with_options(HashMap::from([
+ ("postpone.target-row-num-per-bucket".into(), "1".into()),
+ (
+ "postpone.batch-write-fixed-bucket.max-parallelism".into(),
+ "8".into(),
+ ),
+ ]));
+ let pending = table.new_write_builder();
+ let mut writer = pending.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1, 2, 3, 4], vec![10, 20, 30,
40]))
+ .await
+ .unwrap();
+ pending
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let mut planner = PostponeBucketPlanner::new(&table).unwrap();
+ planner.load_metadata(&table, 1).await.unwrap();
+ planner.add(make_batch(vec![5], vec![50])).unwrap();
+ let (plan, batches) = planner.finish();
+ assert_eq!(
+ plan.total_buckets(&crate::spec::EMPTY_SERIALIZED_ROW),
+ Some(1)
+ );
+ assert_eq!(batches.len(), 1);
+ let mut planner = PostponeBucketPlanner::new(&table).unwrap();
+ planner
+ .known
+ .insert(crate::spec::EMPTY_SERIALIZED_ROW.clone(), 3);
+ let known = planner
+ .add(make_batch(vec![6, 7], vec![60, 70]))
+ .unwrap()
+ .unwrap();
+ assert_eq!(known.num_rows(), 2);
+ assert!(
+ planner.batches.is_empty(),
+ "known partitions must not retain the whole input"
+ );
+ }
+
+ #[test]
+ fn size_inference_rounds_up_then_caps_and_row_target_takes_precedence() {
+ let io = test_file_io();
+ let table = test_postpone_pk_table(&io,
"memory:/auto_postpone_size").copy_with_options(
+ HashMap::from([
+ ("postpone.target-size-per-bucket".into(), "20 b".into()),
+ (
+ "postpone.batch-write-fixed-bucket.max-parallelism".into(),
+ "3".into(),
+ ),
+ ]),
+ );
+ let mut planner = PostponeBucketPlanner::new(&table).unwrap();
+ planner
+ .add(make_batch(vec![1, 2, 3], vec![10, 20, 30]))
+ .unwrap();
+ let (plan, _) = planner.finish();
+ assert_eq!(
+ plan.total_buckets(&crate::spec::EMPTY_SERIALIZED_ROW),
+ Some(3)
+ );
+ let table = table.copy_with_options(HashMap::from([
+ ("postpone.target-row-num-per-bucket".into(), "2".into()),
+ ("postpone.target-size-per-bucket".into(), "invalid".into()),
+ ]));
+ let mut planner = PostponeBucketPlanner::new(&table).unwrap();
+ planner
+ .add(make_batch(vec![1, 2, 3], vec![10, 20, 30]))
+ .unwrap();
+ assert_eq!(
+ planner
+ .finish()
+ .0
+ .total_buckets(&crate::spec::EMPTY_SERIALIZED_ROW),
+ Some(2)
+ );
+ }
+}
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_router.rs
b/crates/paimon/src/table/postpone_fixed_bucket_router.rs
index 56b2c2e2..fe116c7f 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_router.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_router.rs
@@ -43,6 +43,10 @@ pub(crate) struct PostponeFixedBucketRouter {
}
impl PostponeFixedBucketRouter {
+ pub(super) fn set_plan(&mut self, plan: PostponeBucketPlan) {
+ self.plan = plan;
+ }
+
pub(crate) fn new(table: &Table, plan: PostponeBucketPlan) -> Result<Self>
{
validate_postpone_fixed_bucket_table(table)?;
let schema = table.schema();
diff --git a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
index 44565fc6..4f55a352 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write.rs
@@ -16,6 +16,7 @@
// under the License.
use super::postpone_bucket_plan::data_invalid;
+use super::postpone_bucket_planner::PostponeBucketPlanner;
use super::postpone_fixed_bucket_router::{
validate_postpone_fixed_bucket_table, PostponeFixedBucketRouter,
};
@@ -34,6 +35,7 @@ pub struct PostponeFixedBucketTableWrite {
overwrite: bool,
check_from_snapshot: Option<i64>,
prepare_started: bool,
+ planner: Option<PostponeBucketPlanner>,
}
impl PostponeFixedBucketTableWrite {
@@ -42,6 +44,11 @@ impl PostponeFixedBucketTableWrite {
factory: Option<std::sync::Arc<dyn crate::io::UriReaderFactory>>,
) -> Result<&mut Self> {
self.ensure_writable()?;
+ if self.check_from_snapshot.is_some() {
+ return Err(data_invalid(
+ "with_blob_uri_reader_factory must be called before any write
operation",
+ ));
+ }
self.inner.with_blob_uri_reader_factory(factory)?;
Ok(self)
}
@@ -49,12 +56,17 @@ impl PostponeFixedBucketTableWrite {
pub(crate) fn new(
table: &Table,
commit_user: String,
- plan: PostponeBucketPlan,
+ plan: Option<PostponeBucketPlan>,
load_existing_buckets: bool,
overwrite: bool,
resources: Option<ResourceContext>,
) -> Result<Self> {
validate_postpone_fixed_bucket_table(table)?;
+ let planner = if plan.is_none() {
+ Some(PostponeBucketPlanner::new(table)?)
+ } else {
+ None
+ };
let mut inner = TableWrite::new(table, commit_user)?;
if let Some(resources) = resources {
inner = inner.with_resources(resources);
@@ -69,10 +81,11 @@ impl PostponeFixedBucketTableWrite {
} else {
inner
},
- router: PostponeFixedBucketRouter::new(table, plan)?,
+ router: PostponeFixedBucketRouter::new(table,
plan.unwrap_or_default())?,
overwrite,
check_from_snapshot: None,
prepare_started: false,
+ planner,
})
}
@@ -94,13 +107,28 @@ impl PostponeFixedBucketTableWrite {
if self.check_from_snapshot.is_none() {
let snapshot_id = self.inner.pin_sequence_snapshot().await?;
if self.load_existing_buckets {
- self.router
- .load_existing_buckets(&self.table, snapshot_id)
- .await?;
+ if let Some(planner) = &mut self.planner {
+ planner.load_metadata(&self.table, snapshot_id).await?;
+ self.router.set_plan(planner.known_plan());
+ } else {
+ self.router
+ .load_existing_buckets(&self.table, snapshot_id)
+ .await?;
+ }
}
self.check_from_snapshot = Some(snapshot_id);
}
- for routed in self.router.route(&batch)? {
+ if let Some(planner) = &mut self.planner {
+ if let Some(known) = planner.add(batch)? {
+ return self.write_routed_batch(&known).await;
+ }
+ return Ok(());
+ }
+ self.write_routed_batch(&batch).await
+ }
+
+ async fn write_routed_batch(&mut self, batch: &RecordBatch) -> Result<()> {
+ for routed in self.router.route(batch)? {
self.inner
.write_partition_bucket_batch(routed.partition, routed.bucket,
routed.batch)
.await?;
@@ -118,6 +146,22 @@ impl PostponeFixedBucketTableWrite {
pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
self.ensure_writable()?;
self.prepare_started = true;
+ let result = self.prepare_commit_inner().await;
+ if result.is_err() {
+ self.failed = true;
+ self.inner.close().await;
+ }
+ result
+ }
+
+ async fn prepare_commit_inner(&mut self) -> Result<Vec<CommitMessage>> {
+ if let Some(planner) = self.planner.take() {
+ let (plan, batches) = planner.finish();
+ self.router.set_plan(plan);
+ for batch in batches {
+ self.write_routed_batch(&batch).await?;
+ }
+ }
let mut messages = self.inner.prepare_commit().await?;
for message in &mut messages {
message.total_buckets =
self.router.total_buckets(&message.partition);
@@ -132,6 +176,7 @@ impl PostponeFixedBucketTableWrite {
/// Discard unprepared files. Prepared messages and their files belong to
the caller.
pub async fn close(&mut self) {
self.closed = true;
+ self.planner = None;
self.inner.close().await;
}
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 530fa496..661f3207 100644
--- a/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
+++ b/crates/paimon/src/table/postpone_fixed_bucket_write_builder.rs
@@ -15,7 +15,6 @@
// specific language governing permissions and limitations
// under the License.
-use super::postpone_bucket_plan::data_invalid;
use super::postpone_fixed_bucket_router::validate_postpone_fixed_bucket_table;
use crate::resource::ResourceContext;
use crate::spec::CoreOptions;
@@ -85,11 +84,10 @@ impl<'a> PostponeFixedBucketWriteBuilder<'a> {
ensure_table_write_allowed(self.table)?;
let load_existing_buckets = self.bucket_plan.is_none() &&
!self.overwrite;
let plan = match &self.bucket_plan {
- Some(plan) => plan.clone(),
- None => PostponeBucketPlan::with_default_bucket_num(
-
CoreOptions::new(self.table.schema().options()).postpone_default_bucket_num()?
- .ok_or_else(|| data_invalid("A resolved postpone bucket
plan is required unless postpone.default-bucket-num is configured"))?,
- ),
+ Some(plan) => Some(plan.clone()),
+ None => CoreOptions::new(self.table.schema().options())
+ .postpone_default_bucket_num()?
+ .map(PostponeBucketPlan::with_default_bucket_num),
};
PostponeFixedBucketTableWrite::new(
self.table,
@@ -132,13 +130,11 @@ mod tests {
setup_dirs(&file_io, table_path).await;
let table = test_postpone_pk_table(&file_io, table_path);
- let error = table
+ table
.new_postpone_fixed_bucket_write_builder()
.unwrap()
.new_write()
- .err()
.unwrap();
- assert!(error.to_string().contains("bucket plan is required"));
let builder = table
.new_postpone_fixed_bucket_write_builder()
@@ -200,4 +196,37 @@ mod tests {
.all(|message| message.total_buckets == Some(3) &&
(0..3).contains(&message.bucket)));
builder.new_commit().commit(messages).await.unwrap();
}
+
+ #[tokio::test]
+ async fn automatic_bucket_count_uses_the_whole_input() {
+ let io = test_file_io();
+ let path = "memory:/automatic_postpone_count";
+ setup_dirs(&io, path).await;
+ let table =
+ test_postpone_pk_table(&io,
path).copy_with_options(std::collections::HashMap::from([
+ ("postpone.target-row-num-per-bucket".into(), "2".into()),
+ (
+ "postpone.batch-write-fixed-bucket.max-parallelism".into(),
+ "8".into(),
+ ),
+ ]));
+ let builder = table.new_postpone_fixed_bucket_write_builder().unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+ .await
+ .unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![3, 4, 5], vec![30, 40, 50]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert!(!messages.is_empty());
+ assert!(messages.iter().all(|m| m.total_buckets == Some(4)));
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ super::super::table_write::tests::read_id_value_rows(&table).await,
+ vec![(1, 10), (2, 20), (3, 30), (4, 40), (5, 50)]
+ );
+ }
}