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 8cbafd4d Support native Format Table append and overwrite writes (#948)
8cbafd4d is described below
commit 8cbafd4d68b6ebd65ef2dedd1a5f8a60bec5227a
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Sep 25 08:06:11 2026 +0800
Support native Format Table append and overwrite writes (#948)
---
.../datafusion/tests/rest_format_table_dml.rs | 205 +++++-
crates/paimon/src/api/api_request.rs | 10 +
crates/paimon/src/api/rest_api.rs | 33 +
crates/paimon/src/io/file_io.rs | 56 ++
crates/paimon/src/spec/schema.rs | 12 +
crates/paimon/src/table/commit_message.rs | 22 +
crates/paimon/src/table/format_partition.rs | 56 ++
crates/paimon/src/table/format_table_commit.rs | 605 +++++++++++++++++
crates/paimon/src/table/format_table_scan.rs | 10 +-
.../paimon/src/table/format_table_write_tests.rs | 717 +++++++++++++++++++++
crates/paimon/src/table/format_table_writer.rs | 475 ++++++++++++++
crates/paimon/src/table/format_write_builder.rs | 19 +-
crates/paimon/src/table/mod.rs | 4 +
crates/paimon/src/table/table_commit.rs | 26 +
crates/paimon/src/table/table_write.rs | 80 +++
crates/paimon/tests/mock_server.rs | 34 +-
crates/paimon/tests/rest_catalog_test.rs | 2 +-
17 files changed, 2349 insertions(+), 17 deletions(-)
diff --git a/crates/integrations/datafusion/tests/rest_format_table_dml.rs
b/crates/integrations/datafusion/tests/rest_format_table_dml.rs
index 4d08fa80..3494b2ba 100644
--- a/crates/integrations/datafusion/tests/rest_format_table_dml.rs
+++ b/crates/integrations/datafusion/tests/rest_format_table_dml.rs
@@ -15,7 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-//! Row-level DML on a Format Table, which the Rust client cannot write.
+//! Format Table inserts and the row-level mutations that still need a
+//! dedicated copy-on-write implementation.
mod common;
@@ -45,7 +46,7 @@ const WAREHOUSE: &str = "test_warehouse";
/// A Format Table with one file in each of `dt=a` and `dt=b`, whose
partitions a mock REST
/// catalog manages. Its data directory is the root of `temp_dir`.
-async fn catalog_managed_format_table(temp_dir: &TempDir) -> SQLContext {
+async fn catalog_managed_format_table(temp_dir: &TempDir) -> (SQLContext,
mock_server::RESTServer) {
let server = start_mock_server(
WAREHOUSE.to_string(),
temp_dir.path().to_string_lossy().into_owned(),
@@ -81,7 +82,7 @@ async fn catalog_managed_format_table(temp_dir: &TempDir) ->
SQLContext {
let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
let mut context = SQLContext::new();
context.register_catalog("paimon", catalog).await.unwrap();
- context
+ (context, server)
}
/// The same table with its partitions discovered from the directory layout,
in a filesystem
@@ -205,7 +206,7 @@ fn statements(table_name: &str) -> [String; 3] {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_row_level_dml_is_refused_on_a_catalog_managed_format_table() {
let temp_dir = tempfile::tempdir().unwrap();
- let context = catalog_managed_format_table(&temp_dir).await;
+ let (context, _server) = catalog_managed_format_table(&temp_dir).await;
let seeded = files(temp_dir.path());
for statement in statements(TABLE_NAME) {
@@ -230,3 +231,199 @@ async fn
test_row_level_dml_is_refused_on_a_format_table_without_catalog_managed
assert_eq!(ids(&context, &table_name).await, [1, 2, 3], "{statement}");
}
}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_insert_into_catalog_managed_format_table_registers_partition() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (context, _server) = catalog_managed_format_table(&temp_dir).await;
+ context
+ .sql(&format!(
+ "INSERT INTO {TABLE_NAME} (dt, id) VALUES ('a', 10), ('c', 11)"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, TABLE_NAME).await, [1, 2, 3, 10, 11]);
+ assert_eq!(
+ files(&temp_dir.path().join("dt=c"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 1
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_append_keeps_visible_files_when_statistics_report_fails() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (context, server) = catalog_managed_format_table(&temp_dir).await;
+ server.set_create_partitions_statistics_error_status(Some(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ ));
+ context
+ .sql(&format!(
+ "INSERT INTO {TABLE_NAME} (dt, id) VALUES ('c', 11)"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, TABLE_NAME).await, [1, 2, 3, 11]);
+ let calls = server.create_partitions_calls();
+ assert_eq!(calls.len(), 2);
+ assert!(calls[0].2.partition_statistics.is_none());
+ assert!(calls[1].2.partition_statistics.is_some());
+ assert_eq!(
+ files(&temp_dir.path().join("dt=c"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 1
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_failed_partition_preflight_discards_staged_files() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (context, server) = catalog_managed_format_table(&temp_dir).await;
+ let seeded = files(temp_dir.path());
+ server.set_list_partitions_by_names_error_status(Some(
+ axum::http::StatusCode::SERVICE_UNAVAILABLE,
+ ));
+ let error = context
+ .sql(&format!(
+ "INSERT INTO {TABLE_NAME} (dt, id) VALUES ('c', 11)"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("Service unavailable"), "{error}");
+ assert_eq!(files(temp_dir.path()), seeded);
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn
test_overwrite_rebinds_custom_partition_without_deleting_external_data() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (context, server) = catalog_managed_format_table(&temp_dir).await;
+ let external = temp_dir.path().join("external-a");
+ write_ids(&external, &[99]);
+ let spec = HashMap::from([("dt".to_string(), "a".to_string())]);
+ server.set_table_partition_options(
+ DATABASE,
+ TABLE,
+ &spec,
+ HashMap::from([("path".to_string(), format!("file://{}",
external.display()))]),
+ );
+ context
+ .sql(&format!("INSERT OVERWRITE {TABLE_NAME} VALUES ('a', 10)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, TABLE_NAME).await, [3, 10]);
+ assert_eq!(files(&external), ["part-0.parquet"]);
+ let partition = server
+ .table_partitions(DATABASE, TABLE)
+ .into_iter()
+ .find(|partition| partition.spec == spec)
+ .unwrap();
+ let path = partition.options.unwrap().remove("path").unwrap();
+ assert!(path.ends_with("/dt=a"), "{path}");
+ let calls = server.create_partitions_calls();
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].2.replace_statistics, Some(true));
+ assert!(calls[0].2.partition_options.is_some());
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_insert_into_directory_format_table_publishes_visible_files() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (table_dir, context) =
directory_partitioned_format_table(&temp_dir).await;
+ let table_name = format!("paimon.{DATABASE}.{TABLE}");
+ context
+ .sql(&format!(
+ "INSERT INTO {table_name} (dt, id) VALUES ('b', 10), ('c', 11)"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, &table_name).await, [1, 2, 3, 10, 11]);
+ assert_eq!(
+ files(&table_dir.join("dt=b"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 2
+ );
+ assert_eq!(
+ files(&table_dir.join("dt=c"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 1
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_overwrite_only_replaces_touched_catalog_partition() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (context, _server) = catalog_managed_format_table(&temp_dir).await;
+ context
+ .sql(&format!(
+ "INSERT OVERWRITE {TABLE_NAME} VALUES ('a', 10), ('a', 11)"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, TABLE_NAME).await, [3, 10, 11]);
+ assert_eq!(
+ files(&temp_dir.path().join("dt=a"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 1
+ );
+ assert_eq!(
+ files(&temp_dir.path().join("dt=b"))
+ .iter()
+ .filter(|file| file.ends_with(".parquet"))
+ .count(),
+ 1
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_static_partition_overwrite_with_empty_source_keeps_other_data() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (_table_dir, context) =
directory_partitioned_format_table(&temp_dir).await;
+ let table_name = format!("paimon.{DATABASE}.{TABLE}");
+ context
+ .sql(&format!(
+ "INSERT OVERWRITE {table_name} PARTITION (dt = 'a') \
+ SELECT CAST(0 AS BIGINT) AS id WHERE FALSE"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(ids(&context, &table_name).await, [3]);
+}
diff --git a/crates/paimon/src/api/api_request.rs
b/crates/paimon/src/api/api_request.rs
index 7cc1febd..679ddeb8 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -209,6 +209,9 @@ pub struct CreatePartitionsRequest {
/// present only together with `partition_statistics`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replace_statistics: Option<bool>,
+ /// Per-partition catalog options, aligned with `partition_specs`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub partition_options: Option<Vec<HashMap<String, String>>>,
}
impl CreatePartitionsRequest {
@@ -219,6 +222,7 @@ impl CreatePartitionsRequest {
ignore_if_exists,
partition_statistics: None,
replace_statistics: None,
+ partition_options: None,
}
}
@@ -228,6 +232,12 @@ impl CreatePartitionsRequest {
self.replace_statistics = Some(replace);
self
}
+
+ /// Set catalog options for each partition in the request.
+ pub fn with_partition_options(mut self, options: Vec<HashMap<String,
String>>) -> Self {
+ self.partition_options = Some(options);
+ self
+ }
}
/// Request to drop (unregister) table partitions.
diff --git a/crates/paimon/src/api/rest_api.rs
b/crates/paimon/src/api/rest_api.rs
index e664ace9..7e435ae1 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -624,15 +624,48 @@ impl RESTApi {
ignore_if_exists: bool,
statistics: Option<Vec<PartitionStatistics>>,
replace_statistics: bool,
+ ) -> Result<()> {
+ self.create_partitions_with_options(
+ identifier,
+ partition_specs,
+ ignore_if_exists,
+ statistics,
+ replace_statistics,
+ None,
+ )
+ .await
+ }
+
+ /// Create partitions with aligned statistics and per-partition catalog
options.
+ pub async fn create_partitions_with_options(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_exists: bool,
+ statistics: Option<Vec<PartitionStatistics>>,
+ replace_statistics: bool,
+ partition_options: Option<Vec<HashMap<String, String>>>,
) -> Result<()> {
let database = identifier.database();
let table = identifier.object();
validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
+ if partition_options
+ .as_ref()
+ .is_some_and(|options| options.len() != partition_specs.len())
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "Partition options must align with partition
specs".into(),
+ source: None,
+ });
+ }
let path = self.resource_paths.partitions(database, table);
let mut request = CreatePartitionsRequest::new(partition_specs,
ignore_if_exists);
if let Some(statistics) = statistics {
request = request.with_statistics(statistics, replace_statistics);
}
+ if let Some(options) = partition_options {
+ request = request.with_partition_options(options);
+ }
let _resp: serde_json::Value = self.client.post(&path,
&request).await?;
Ok(())
}
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index 720225eb..bc8e2fb5 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -459,6 +459,37 @@ impl FileIO {
Ok(())
}
+ /// Copy a large file without materializing its entire contents in memory.
+ /// Format Table publication uses this when a backend does not support
+ /// rename, as is common for object stores and the in-memory test backend.
+ pub async fn copy_file_streaming(&self, src: &str, dst: &str) ->
Result<()> {
+ const CHUNK_SIZE: u64 = 8 * 1024 * 1024;
+ let input = self.new_input(src)?;
+ let size = input.metadata().await?.size;
+ let reader = input.reader().await?;
+ let output = self.new_output(dst)?;
+ let mut writer = output.writer().await?;
+ let mut position = 0;
+ while position < size {
+ let end = (position + CHUNK_SIZE).min(size);
+ let bytes = reader.read(position..end).await?;
+ if bytes.len() as u64 != end - position {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Short read while copying '{src}': expected {} bytes,
got {}",
+ end - position,
+ bytes.len()
+ ),
+ source: None,
+ });
+ }
+ writer.write(bytes).await?;
+ position = end;
+ }
+ writer.close().await?;
+ Ok(())
+ }
+
/// Renames the file/directory src to dst.
///
/// Reference:
<https://github.com/apache/paimon/blob/release-0.8.2/paimon-common/src/main/java/org/apache/paimon/fs/FileIO.java#L159>
@@ -1901,6 +1932,31 @@ mod input_output_test {
);
}
+ #[tokio::test]
+ async fn test_streaming_copy_crosses_chunk_boundary() {
+ let file_io = setup_memory_file_io();
+ let source = "memory:/format-copy/source.parquet";
+ let target = "memory:/format-copy/target.parquet";
+ let mut payload = vec![0u8; 8 * 1024 * 1024 + 17];
+ payload[0] = 3;
+ payload[8 * 1024 * 1024 - 1] = 7;
+ payload[8 * 1024 * 1024] = 11;
+ payload[8 * 1024 * 1024 + 16] = 13;
+ file_io
+ .new_output(source)
+ .unwrap()
+ .write(Bytes::from(payload.clone()))
+ .await
+ .unwrap();
+
+ file_io.copy_file_streaming(source, target).await.unwrap();
+ assert_eq!(
+ file_io.new_input(target).unwrap().read().await.unwrap(),
+ Bytes::from(payload)
+ );
+ assert!(file_io.exists(source).await.unwrap());
+ }
+
#[cfg(not(windows))]
#[tokio::test]
async fn
test_file_io_local_cache_invalidates_source_and_target_after_rename() {
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 11dc4365..56651d9f 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -1075,6 +1075,8 @@ pub struct DataField {
typ: DataType,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
+ #[serde(rename = "defaultValue", skip_serializing_if = "Option::is_none")]
+ default_value: Option<String>,
}
impl DataField {
@@ -1084,6 +1086,7 @@ impl DataField {
name,
typ,
description: None,
+ default_value: None,
}
}
@@ -1103,6 +1106,10 @@ impl DataField {
self.description.as_deref()
}
+ pub fn default_value(&self) -> Option<&str> {
+ self.default_value.as_deref()
+ }
+
pub fn with_id(mut self, new_id: i32) -> Self {
self.id = new_id;
self
@@ -1117,6 +1124,11 @@ impl DataField {
self.description = new_description;
self
}
+
+ pub fn with_default_value(mut self, default_value: Option<String>) -> Self
{
+ self.default_value = default_value;
+ self
+ }
}
/// Quote an identifier the way Java `EncodingUtils.escapeIdentifier` does:
wrap it
diff --git a/crates/paimon/src/table/commit_message.rs
b/crates/paimon/src/table/commit_message.rs
index f4e86ba0..29c904d1 100644
--- a/crates/paimon/src/table/commit_message.rs
+++ b/crates/paimon/src/table/commit_message.rs
@@ -17,6 +17,7 @@
use super::source::{read_i32, read_i64, take};
use crate::spec::{BinaryRow, DataFileMeta, DataFileMetaRowLayout,
IndexFileMeta};
+use std::collections::HashMap;
/// Current Java `CommitMessageSerializer` body version. The version is carried
/// by an enclosing serializer, not embedded in the body.
@@ -71,6 +72,9 @@ fn read_rows<T>(
/// Reference:
[org.apache.paimon.table.sink.CommitMessage](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/sink/CommitMessageImpl.java)
#[derive(Debug, Clone)]
pub struct CommitMessage {
+ /// A staged Format Table file. Format Tables have no manifest or snapshot;
+ /// this message is published by the Format Table committer instead.
+ pub(crate) format_file: Option<FormatFileCommit>,
/// Binary row bytes for the partition.
pub partition: Vec<u8>,
/// Bucket id.
@@ -102,6 +106,7 @@ pub struct CommitMessage {
impl CommitMessage {
pub fn new(partition: Vec<u8>, bucket: i32, new_files: Vec<DataFileMeta>)
-> Self {
Self {
+ format_file: None,
partition,
bucket,
total_buckets: None,
@@ -132,6 +137,12 @@ impl CommitMessage {
/// Write the unframed Java v14 `CommitMessageSerializer.serialize` body.
pub fn serialize(&self) -> crate::Result<Vec<u8>> {
+ if self.format_file.is_some() {
+ return Err(crate::Error::Unsupported {
+ message: "Format Table two-phase file messages use a different
Java serializer"
+ .into(),
+ });
+ }
let mut out = Vec::new();
// The partition normally is SerializationUtils.serializeBinaryRow:
// i32 arity followed by a raw BinaryRow. Internal unpartitioned
writers
@@ -225,6 +236,7 @@ impl CommitMessage {
)));
}
Ok(Self {
+ format_file: None,
partition,
bucket,
total_buckets,
@@ -256,6 +268,16 @@ impl CommitMessage {
}
}
+/// A file prepared below `_temporary`, awaiting publish into its partition.
+#[derive(Debug, Clone)]
+pub(crate) struct FormatFileCommit {
+ pub staged_path: String,
+ pub target_path: String,
+ pub partition: HashMap<String, String>,
+ pub record_count: i64,
+ pub file_size: i64,
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/paimon/src/table/format_partition.rs
b/crates/paimon/src/table/format_partition.rs
index 0f27e9a0..748c8b28 100644
--- a/crates/paimon/src/table/format_partition.rs
+++ b/crates/paimon/src/table/format_partition.rs
@@ -111,6 +111,44 @@ impl FormatTablePartitionPaths {
.join("/"))
}
+ /// Physical path for a leading static partition prefix. Java creates this
+ /// directory even when an overwrite writes no rows. An empty spec denotes
+ /// the table root; a gap or an unknown key is rejected.
+ pub(crate) fn relative_prefix_path(
+ &self,
+ spec: &HashMap<String, String>,
+ ) -> crate::Result<String> {
+ if spec.len() > self.partition_keys.len() {
+ return Err(crate::Error::DataInvalid {
+ message: "Static partition is not a leading prefix".into(),
+ source: None,
+ });
+ }
+ let mut segments = Vec::with_capacity(spec.len());
+ for key in self.partition_keys.iter().take(spec.len()) {
+ let value = spec.get(key).ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("Static partition is missing leading key
'{key}'"),
+ source: None,
+ })?;
+ if value.is_empty() || (self.only_value_in_path &&
matches!(value.as_str(), "." | ".."))
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Partition value {value:?} cannot be used as a path
component"
+ ),
+ source: None,
+ });
+ }
+ let segment = if self.only_value_in_path {
+ escape_path_name(value)
+ } else {
+ format!("{}={}", escape_path_name(key),
escape_path_name(value))
+ };
+ segments.push(segment);
+ }
+ Ok(segments.join("/"))
+ }
+
/// Discover complete raw partition specs from the table directory, sorted
and deduplicated.
/// Skips hidden or non-matching entries; a malformed or non-canonical
segment is an error.
pub async fn discover(
@@ -421,6 +459,24 @@ mod tests {
assert!(value_only.relative_path(&traversal).is_err());
}
+ #[test]
+ fn test_relative_static_partition_prefix_path() {
+ let keyed = FormatTablePartitionPaths::new(["dt", "hour"], false);
+ let values = FormatTablePartitionPaths::new(["dt", "hour"], true);
+ let empty = HashMap::new();
+ assert_eq!(keyed.relative_prefix_path(&empty).unwrap(), "");
+ assert_eq!(values.relative_prefix_path(&empty).unwrap(), "");
+ let prefix = HashMap::from([("dt".to_string(), "a/b".to_string())]);
+ assert_eq!(keyed.relative_prefix_path(&prefix).unwrap(), "dt=a%2Fb");
+ assert_eq!(values.relative_prefix_path(&prefix).unwrap(), "a%2Fb");
+ let gap = HashMap::from([("hour".to_string(), "12".to_string())]);
+ assert!(keyed.relative_prefix_path(&gap).is_err());
+ let unknown = HashMap::from([("other".to_string(), "x".to_string())]);
+ assert!(values.relative_prefix_path(&unknown).is_err());
+ let invalid = HashMap::from([("dt".to_string(), "..".to_string())]);
+ assert!(values.relative_prefix_path(&invalid).is_err());
+ }
+
#[test]
fn test_name_prefix_pattern() {
let paths = FormatTablePartitionPaths::new(["dt".to_string(),
"hh".to_string()], false);
diff --git a/crates/paimon/src/table/format_table_commit.rs
b/crates/paimon/src/table/format_table_commit.rs
new file mode 100644
index 00000000..ee4a7822
--- /dev/null
+++ b/crates/paimon/src/table/format_table_commit.rs
@@ -0,0 +1,605 @@
+// 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.
+
+//! Publish prepared Format Table files without creating a Paimon snapshot.
+//! Java's `FormatTableCommit` treats published data and catalog partition
+//! registration as separate side effects, with different rollback rules before
+//! and after a partition becomes visible to readers.
+
+use std::collections::{HashMap, HashSet};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use super::commit_message::{CommitMessage, FormatFileCommit};
+use super::format_partition::FormatTablePartitionPaths;
+use super::format_table_scan::list_format_table_files;
+use super::Table;
+use crate::spec::{CoreOptions, Datum, Partition, PartitionStatistics};
+use crate::Result;
+
+pub(crate) struct FormatTableCommit<'a> {
+ table: &'a Table,
+ table_path: String,
+ paths: FormatTablePartitionPaths,
+ default_partition_name: String,
+ dynamic_partition_overwrite: bool,
+}
+
+impl<'a> FormatTableCommit<'a> {
+ pub(crate) fn new(table: &'a Table) -> Self {
+ let options = CoreOptions::new(table.schema().options());
+ Self {
+ table,
+ table_path: options
+ .path()
+ .unwrap_or_else(|| table.location())
+ .trim_end_matches('/')
+ .to_string(),
+ paths: FormatTablePartitionPaths::new(
+ table.schema().partition_keys().iter().cloned(),
+ options.format_table_partition_only_value_in_path(),
+ ),
+ default_partition_name:
options.partition_default_name().to_string(),
+ dynamic_partition_overwrite: table
+ .schema()
+ .options()
+ .get("dynamic-partition-overwrite")
+ .is_none_or(|value| value.eq_ignore_ascii_case("true")),
+ }
+ }
+
+ pub(crate) async fn append(&self, messages: &[CommitMessage]) ->
Result<()> {
+ self.apply(messages, None).await
+ }
+
+ pub(crate) async fn overwrite(
+ &self,
+ messages: &[CommitMessage],
+ static_partition: Option<&HashMap<String, Option<Datum>>>,
+ ) -> Result<()> {
+ self.apply(messages, Some(static_partition)).await
+ }
+
+ pub(crate) async fn abort(&self, messages: &[CommitMessage]) -> Result<()>
{
+ for message in messages {
+ if let Some(file) = &message.format_file {
+ self.table.file_io().delete_file(&file.staged_path).await?;
+ }
+ }
+ Ok(())
+ }
+
+ /// `None` is append; `Some(None)` is an overwrite without static
+ /// partitions; `Some(Some(spec))` selects the leading static prefix.
+ async fn apply(
+ &self,
+ messages: &[CommitMessage],
+ overwrite: Option<Option<&HashMap<String, Option<Datum>>>>,
+ ) -> Result<()> {
+ let result = self.apply_inner(messages, overwrite).await;
+ if result.is_err() {
+ let _ = self.abort(messages).await;
+ }
+ result
+ }
+
+ async fn apply_inner(
+ &self,
+ messages: &[CommitMessage],
+ overwrite: Option<Option<&HashMap<String, Option<Datum>>>>,
+ ) -> Result<()> {
+ self.table.ensure_not_branch_reference_for_write()?;
+ let files = self.validate_messages(messages).await?;
+ let managed = self.table.has_catalog_managed_partitions();
+ let requested = files
+ .iter()
+ .map(|file| file.partition.clone())
+ .collect::<Vec<_>>();
+ let selected = if let Some(static_partition) = overwrite {
+ self.selected_overwrite_partitions(&requested, static_partition)
+ .await?
+ } else {
+ Vec::new()
+ };
+ let static_prefix = match overwrite {
+ Some(Some(spec)) => self.static_prefix(Some(spec))?,
+ _ => HashMap::new(),
+ };
+ if files.iter().any(|file| {
+ !static_prefix
+ .iter()
+ .all(|(key, value)| file.partition.get(key) == Some(value))
+ }) {
+ return Err(crate::Error::DataInvalid {
+ message: "Format Table output is outside the static overwrite
partition".into(),
+ source: None,
+ });
+ }
+
+ // Validate registry paths before deleting or publishing anything. A
+ // partition with a custom location cannot be written through the table
+ // directory; Java rejects it for the same reason.
+ if managed {
+ let mut touched = requested.clone();
+ touched.extend(selected.iter().cloned());
+ self.validate_registered_partitions(&touched, overwrite.is_none())
+ .await?;
+ }
+
+ if overwrite.is_some() {
+ for spec in &selected {
+ let directory = self.partition_directory(spec)?;
+ for status in list_format_table_files(self.table.file_io(),
&directory, 0, None)
+ .await?
+ .into_iter()
+ .filter(|status| !status.is_dir)
+ {
+ self.table.file_io().delete_file(&status.path).await?;
+ }
+ }
+ if !static_prefix.is_empty() {
+ let relative =
self.paths.relative_prefix_path(&static_prefix)?;
+ self.table
+ .file_io()
+ .mkdirs(&format!("{}/{relative}/", self.table_path))
+ .await?;
+ }
+ }
+
+ let mut published: Vec<String> = Vec::new();
+ for file in &files {
+ let result = self.publish(file).await;
+ if let Err(error) = result {
+ // An overwrite has already removed old files, so replacement
+ // files must survive an uncertain partial publish. Append can
+ // safely roll back files this attempt uniquely named.
+ if overwrite.is_none() {
+ for path in
published.iter().chain(std::iter::once(&file.target_path)) {
+ let _ = self.table.file_io().delete_file(path).await;
+ }
+ }
+ self.discard_staging(&files).await;
+ return Err(error);
+ }
+ published.push(file.target_path.clone());
+ }
+ self.discard_staging(&files).await;
+
+ if managed {
+ let stats = self.partition_statistics(&files, &selected,
overwrite.is_some());
+ let specs = stats
+ .iter()
+ .map(|stat| stat.spec.clone())
+ .collect::<Vec<_>>();
+ if !specs.is_empty() {
+ let env = self
+ .table
+ .rest_env()
+ .expect("managed partition REST environment");
+ if overwrite.is_some() {
+ let options = specs
+ .iter()
+ .map(|spec| {
+ Ok(HashMap::from([(
+ "path".to_string(),
+ self.catalog_partition_path(spec)?,
+ )]))
+ })
+ .collect::<Result<Vec<_>>>()?;
+ // Replacement data survives a metadata failure because old
+ // data has already been removed.
+ env.api()
+ .create_partitions_with_options(
+ env.identifier(),
+ specs,
+ true,
+ Some(stats),
+ true,
+ Some(options),
+ )
+ .await?;
+ } else {
+ // Java registers first, then preserves visible files even
+ // if the later additive statistics report fails.
+ if let Err(error) = env
+ .api()
+ .create_partitions(env.identifier(), specs.clone(),
true)
+ .await
+ {
+ for path in &published {
+ let _ =
self.table.file_io().delete_file(path).await;
+ }
+ return Err(error);
+ }
+ if let Err(error) = env
+ .api()
+ .create_partitions_with_statistics(
+ env.identifier(),
+ specs,
+ true,
+ Some(stats),
+ false,
+ )
+ .await
+ {
+ log::warn!(
+ "Committed Format Table data but failed to report
append statistics: {error}"
+ );
+ }
+ }
+ }
+ }
+ Ok(())
+ }
+
+ async fn validate_messages(&self, messages: &[CommitMessage]) ->
Result<Vec<FormatFileCommit>> {
+ let mut files = Vec::with_capacity(messages.len());
+ let mut targets = HashSet::new();
+ for message in messages {
+ let file = message
+ .format_file
+ .as_ref()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Format Table commit requires staged Format Table
file messages"
+ .into(),
+ source: None,
+ })?;
+ let expected_directory =
self.partition_directory(&file.partition)?;
+ let parent = file.target_path.rsplit_once('/').map(|(parent, _)|
parent);
+ if parent != Some(expected_directory.as_str()) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Format Table target is outside partition: {}",
+ file.target_path
+ ),
+ source: None,
+ });
+ }
+ if !file
+ .staged_path
+ .starts_with(&format!("{}/_temporary/", self.table_path))
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Format Table staging path is outside table: {}",
+ file.staged_path
+ ),
+ source: None,
+ });
+ }
+ if !targets.insert(file.target_path.as_str()) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Duplicate Format Table target: {}",
file.target_path),
+ source: None,
+ });
+ }
+ if !self.table.file_io().exists(&file.staged_path).await? {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Staged Format Table file is missing:
{}", file.staged_path),
+ source: None,
+ });
+ }
+ if self.table.file_io().exists(&file.target_path).await? {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Format Table target already exists: {}",
file.target_path),
+ source: None,
+ });
+ }
+ files.push(file.clone());
+ }
+ Ok(files)
+ }
+
+ async fn validate_registered_partitions(
+ &self,
+ specs: &[HashMap<String, String>],
+ reject_custom_location: bool,
+ ) -> Result<()> {
+ if specs.is_empty() {
+ return Ok(());
+ }
+ let env = self
+ .table
+ .rest_env()
+ .expect("managed partition REST environment");
+ let registered = env
+ .api()
+ .list_partitions_by_names(env.identifier(), specs.to_vec())
+ .await?;
+ let requested = specs.iter().map(spec_key).collect::<HashSet<_>>();
+ for partition in registered {
+ if !requested.contains(&spec_key(&partition.spec)) {
+ return Err(crate::Error::DataInvalid {
+ message: "Catalog returned an unrequested Format Table
partition".into(),
+ source: None,
+ });
+ }
+ if reject_custom_location
+ && partition
+ .options
+ .as_ref()
+ .is_some_and(|options| options.contains_key("path"))
+ {
+ return Err(crate::Error::Unsupported {
+ message:
+ "Writing a Format Table partition with a custom
location is not supported"
+ .into(),
+ });
+ }
+ }
+ Ok(())
+ }
+
+ fn partition_directory(&self, spec: &HashMap<String, String>) ->
Result<String> {
+ let relative = self.paths.relative_path(spec)?;
+ Ok(if relative.is_empty() {
+ self.table_path.clone()
+ } else {
+ format!("{}/{relative}", self.table_path)
+ })
+ }
+
+ fn catalog_partition_path(&self, spec: &HashMap<String, String>) ->
Result<String> {
+ let path = self.partition_directory(spec)?;
+ if url::Url::parse(&path).is_ok() {
+ return Ok(path);
+ }
+ let absolute = std::path::Path::new(&path);
+ let absolute = if absolute.is_absolute() {
+ absolute.to_path_buf()
+ } else {
+ std::env::current_dir()
+ .map_err(|error| crate::Error::ConfigInvalid {
+ message: format!("Cannot resolve Format Table partition
path: {error}"),
+ })?
+ .join(absolute)
+ };
+ url::Url::from_file_path(&absolute)
+ .map(|url| url.to_string())
+ .map_err(|_| crate::Error::DataInvalid {
+ message: format!("Invalid Format Table partition path:
{path}"),
+ source: None,
+ })
+ }
+
+ async fn publish(&self, file: &FormatFileCommit) -> Result<()> {
+ let directory = self.partition_directory(&file.partition)?;
+ self.table
+ .file_io()
+ .mkdirs(&format!("{directory}/"))
+ .await?;
+ // Java opens each target with overwrite=false. The UUID-based name and
+ // preflight existence check make a collision extremely unlikely; test
+ // once more immediately before moving a file into place.
+ if self.table.file_io().exists(&file.target_path).await? {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Format Table target already exists: {}",
file.target_path),
+ source: None,
+ });
+ }
+ match self
+ .table
+ .file_io()
+ .rename(&file.staged_path, &file.target_path)
+ .await
+ {
+ Ok(()) => Ok(()),
+ Err(crate::Error::IoUnexpected { source, .. })
+ if source.kind() == opendal::ErrorKind::Unsupported =>
+ {
+ let result = self
+ .table
+ .file_io()
+ .copy_file_streaming(&file.staged_path, &file.target_path)
+ .await;
+ if result.is_err() {
+ let _ =
self.table.file_io().delete_file(&file.target_path).await;
+ }
+ result
+ }
+ Err(error) => Err(error),
+ }
+ }
+
+ async fn discard_staging(&self, files: &[FormatFileCommit]) {
+ for file in files {
+ let _ = self.table.file_io().delete_file(&file.staged_path).await;
+ }
+ }
+
+ async fn selected_overwrite_partitions(
+ &self,
+ written: &[HashMap<String, String>],
+ static_partition: Option<&HashMap<String, Option<Datum>>>,
+ ) -> Result<Vec<HashMap<String, String>>> {
+ let keys = self.table.schema().partition_keys();
+ if keys.is_empty() {
+ if static_partition.is_some_and(|spec| !spec.is_empty()) {
+ return Err(crate::Error::DataInvalid {
+ message: "An unpartitioned Format Table cannot have a
static partition".into(),
+ source: None,
+ });
+ }
+ return Ok(vec![HashMap::new()]);
+ }
+ let prefix = self.static_prefix(static_partition)?;
+ if prefix.is_empty() && self.dynamic_partition_overwrite {
+ return Ok(deduplicate_specs(written));
+ }
+ let candidates = if self.table.has_catalog_managed_partitions() {
+ let env = self
+ .table
+ .rest_env()
+ .expect("managed partition REST environment");
+ env.api()
+ .list_partitions(env.identifier())
+ .await?
+ .into_iter()
+ .map(|part| part.spec)
+ .collect::<Vec<_>>()
+ } else {
+ self.paths
+ .discover(
+ self.table.file_io(),
+ &self.table_path,
+ &self.default_partition_name,
+ )
+ .await?
+ };
+ let mut selected = candidates
+ .into_iter()
+ .filter(|spec| {
+ prefix
+ .iter()
+ .all(|(key, value)| spec.get(key) == Some(value))
+ })
+ .collect::<Vec<_>>();
+ selected.extend(
+ written
+ .iter()
+ .filter(|spec| {
+ prefix
+ .iter()
+ .all(|(key, value)| spec.get(key) == Some(value))
+ })
+ .cloned(),
+ );
+ if prefix.len() == keys.len() {
+ selected.push(prefix);
+ }
+ Ok(deduplicate_specs(&selected))
+ }
+
+ fn static_prefix(
+ &self,
+ static_partition: Option<&HashMap<String, Option<Datum>>>,
+ ) -> Result<HashMap<String, String>> {
+ let Some(static_partition) = static_partition else {
+ return Ok(HashMap::new());
+ };
+ let keys = self.table.schema().partition_keys();
+ let mut prefix = HashMap::new();
+ let mut missing = false;
+ for key in keys {
+ match static_partition.get(key) {
+ Some(_) if missing => {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Static partition '{key}' lacks its
leading partition"),
+ source: None,
+ });
+ }
+ Some(value) => {
+ let field = self
+ .table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == key)
+ .expect("partition field");
+ let text = match value {
+ None => self.default_partition_name.clone(),
+ Some(datum) =>
super::format_partition::format_partition_value(
+ datum,
+ field.data_type(),
+ &self.default_partition_name,
+ CoreOptions::new(self.table.schema().options())
+ .legacy_partition_name(),
+ )
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Static partition '{key}' has a value
incompatible with its data type"
+ ),
+ source: None,
+ })?,
+ };
+ prefix.insert(key.clone(), text);
+ }
+ None => missing = true,
+ }
+ }
+ if static_partition.keys().any(|key| !keys.contains(key)) {
+ return Err(crate::Error::DataInvalid {
+ message: "Unknown static Format Table partition column".into(),
+ source: None,
+ });
+ }
+ Ok(prefix)
+ }
+
+ fn partition_statistics(
+ &self,
+ files: &[FormatFileCommit],
+ selected: &[HashMap<String, String>],
+ overwrite: bool,
+ ) -> Vec<PartitionStatistics> {
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as i64;
+ let mut by_spec: HashMap<Vec<(String, String)>, PartitionStatistics> =
HashMap::new();
+ if overwrite {
+ for spec in selected {
+ let key = spec_key(spec);
+ by_spec.insert(
+ key,
+ PartitionStatistics {
+ spec: spec.clone(),
+ record_count: 0,
+ file_size_in_bytes: 0,
+ file_count: 0,
+ last_file_creation_time: now,
+ total_buckets: Partition::UNKNOWN_TOTAL_BUCKETS,
+ },
+ );
+ }
+ }
+ for file in files {
+ let stat =
+ by_spec
+ .entry(spec_key(&file.partition))
+ .or_insert_with(|| PartitionStatistics {
+ spec: file.partition.clone(),
+ record_count: 0,
+ file_size_in_bytes: 0,
+ file_count: 0,
+ last_file_creation_time: now,
+ total_buckets: Partition::UNKNOWN_TOTAL_BUCKETS,
+ });
+ stat.record_count += file.record_count;
+ stat.file_size_in_bytes += file.file_size;
+ stat.file_count += 1;
+ }
+ by_spec.into_values().collect()
+ }
+}
+
+fn deduplicate_specs(specs: &[HashMap<String, String>]) -> Vec<HashMap<String,
String>> {
+ let mut seen = HashSet::new();
+ specs
+ .iter()
+ .filter(|spec| seen.insert(spec_key(spec)))
+ .cloned()
+ .collect()
+}
+
+fn spec_key(spec: &HashMap<String, String>) -> Vec<(String, String)> {
+ let mut key = spec
+ .iter()
+ .map(|(key, value)| (key.clone(), value.clone()))
+ .collect::<Vec<_>>();
+ key.sort();
+ key
+}
diff --git a/crates/paimon/src/table/format_table_scan.rs
b/crates/paimon/src/table/format_table_scan.rs
index 7297b1c5..227fbc5b 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -295,12 +295,16 @@ impl<'a> FormatTableScan<'a> {
if !self.partition_matches(&row)? {
continue;
}
- // The Rust reader cannot resolve a partition's own location yet,
and reading the
- // default directory in its place would return whatever happens to
be there.
+ // An overwrite explicitly rebinds a formerly custom partition to
+ // its default directory. That path option is safe to read here;
+ // other custom locations still need their own resolver.
if partition
.options
.as_ref()
- .is_some_and(|options| options.contains_key(PATH_OPTION))
+ .and_then(|options| options.get(PATH_OPTION))
+ .is_some_and(|location| {
+ location.trim_end_matches('/') !=
path.trim_end_matches('/')
+ })
{
return Err(crate::Error::Unsupported {
message: format!(
diff --git a/crates/paimon/src/table/format_table_write_tests.rs
b/crates/paimon/src/table/format_table_write_tests.rs
new file mode 100644
index 00000000..9b4bd883
--- /dev/null
+++ b/crates/paimon/src/table/format_table_write_tests.rs
@@ -0,0 +1,717 @@
+// 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.
+
+//! Format Table write behavior is tested through the same public builder,
+//! scan and read calls used by native callers.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Int32Array, RecordBatch, StringArray};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use futures::TryStreamExt;
+
+use super::Table;
+use crate::catalog::Identifier;
+use crate::io::{FileIO, FileIOBuilder};
+use crate::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+
+fn table(io: FileIO, location: &str, partitioned: bool, options: &[(&str,
&str)]) -> Table {
+ let mut builder = Schema::builder();
+ if partitioned {
+ builder = builder.column("dt",
DataType::VarChar(VarCharType::string_type()));
+ }
+ builder = builder
+ .column("id", DataType::Int(IntType::new()))
+ .option("type", "format-table")
+ .option("file.format", "parquet");
+ if partitioned {
+ builder = builder.partition_keys(["dt"]);
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ Table::new(
+ io,
+ Identifier::new("default", "format_write"),
+ location.to_string(),
+ TableSchema::new(0, &builder.build().unwrap()),
+ None,
+ )
+}
+
+fn memory_table(name: &str, partitioned: bool, options: &[(&str, &str)]) ->
Table {
+ table(
+ FileIOBuilder::new("memory").build().unwrap(),
+ &format!("memory:/{name}"),
+ partitioned,
+ options,
+ )
+}
+
+fn two_partition_table(name: &str) -> Table {
+ let schema = Schema::builder()
+ .column("dt", DataType::VarChar(VarCharType::string_type()))
+ .column("hour", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .partition_keys(["dt", "hour"])
+ .option("type", "format-table")
+ .option("file.format", "parquet")
+ .build()
+ .unwrap();
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "format_two_partition"),
+ format!("memory:/{name}"),
+ TableSchema::new(0, &schema),
+ None,
+ )
+}
+
+fn two_partition_batch(rows: &[(&str, &str, i32)]) -> RecordBatch {
+ RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ Field::new("dt", ArrowType::Utf8, true),
+ Field::new("hour", ArrowType::Utf8, true),
+ Field::new("id", ArrowType::Int32, true),
+ ])),
+ vec![
+ Arc::new(StringArray::from(
+ rows.iter().map(|row| row.0).collect::<Vec<_>>(),
+ )),
+ Arc::new(StringArray::from(
+ rows.iter().map(|row| row.1).collect::<Vec<_>>(),
+ )),
+ Arc::new(Int32Array::from(
+ rows.iter().map(|row| row.2).collect::<Vec<_>>(),
+ )),
+ ],
+ )
+ .unwrap()
+}
+
+fn batch(rows: &[(&str, i32)]) -> RecordBatch {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ Field::new("dt", ArrowType::Utf8, true),
+ Field::new("id", ArrowType::Int32, true),
+ ]));
+ RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(StringArray::from(
+ rows.iter().map(|row| row.0).collect::<Vec<_>>(),
+ )),
+ Arc::new(Int32Array::from(
+ rows.iter().map(|row| row.1).collect::<Vec<_>>(),
+ )),
+ ],
+ )
+ .unwrap()
+}
+
+fn unpartitioned_batch(ids: &[i32]) -> RecordBatch {
+ RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![Field::new(
+ "id",
+ ArrowType::Int32,
+ true,
+ )])),
+ vec![Arc::new(Int32Array::from(ids.to_vec()))],
+ )
+ .unwrap()
+}
+
+async fn ids(table: &Table) -> Vec<i32> {
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ let read = table.new_read_builder().new_read().unwrap();
+ let batches: Vec<RecordBatch> = read
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut ids = Vec::new();
+ for batch in batches {
+ let index = batch.schema().index_of("id").unwrap();
+ let column = batch
+ .column(index)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ ids.extend(column.iter().flatten());
+ }
+ ids.sort_unstable();
+ ids
+}
+
+async fn visible_files(table: &Table, partition: &str) -> Vec<String> {
+ let path = if partition.is_empty() {
+ table.location().to_string()
+ } else {
+ format!("{}/{partition}", table.location())
+ };
+ let mut files =
+ super::format_table_scan::list_format_table_files(table.file_io(),
&path, 0, None)
+ .await
+ .unwrap()
+ .into_iter()
+ .filter(|status| !status.is_dir)
+ .map(|status| status.path)
+ .collect::<Vec<_>>();
+ files.sort();
+ files
+}
+
+async fn append(table: &Table, batch: &RecordBatch) {
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(batch).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+}
+
+#[tokio::test]
+async fn append_partitioned_table_writes_data_without_partition_column() {
+ let table = memory_table("format_append_partitioned", true, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&batch(&[("a", 1), ("b", 2), ("a", 3)]))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 2);
+ for message in &messages {
+ let file = message.format_file.as_ref().unwrap();
+ assert!(file.staged_path.contains("/_temporary/"));
+ assert!(!table.file_io().exists(&file.target_path).await.unwrap());
+ assert_eq!(file.partition.len(), 1);
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(ids(&table).await, [1, 2, 3]);
+ assert_eq!(visible_files(&table, "dt=a").await.len(), 1);
+ assert_eq!(visible_files(&table, "dt=b").await.len(), 1);
+}
+
+#[tokio::test]
+async fn append_unpartitioned_table_has_no_snapshot() {
+ let table = memory_table("format_append_plain", false, &[]);
+ append(&table, &unpartitioned_batch(&[4, 5])).await;
+ append(&table, &unpartitioned_batch(&[6])).await;
+ assert_eq!(ids(&table).await, [4, 5, 6]);
+ assert_eq!(visible_files(&table, "").await.len(), 2);
+ assert!(table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .is_none());
+}
+
+#[tokio::test]
+async fn format_table_ignores_paimon_bucket_configuration() {
+ let table = memory_table("format_bucket_ignored", true, &[("bucket",
"2")]);
+ append(&table, &batch(&[("a", 1)])).await;
+ assert_eq!(ids(&table).await, [1]);
+}
+
+#[test]
+fn declared_column_default_is_rejected_before_opening_a_writer() {
+ let table = memory_table("format_column_default", true, &[]);
+ let mut schema = serde_json::to_value(table.schema()).unwrap();
+ schema["fields"][0]["defaultValue"] = serde_json::json!("'fallback'");
+ let schema = serde_json::from_value(schema).unwrap();
+ let table = Table::new(
+ table.file_io().clone(),
+ table.identifier().clone(),
+ table.location().to_string(),
+ schema,
+ None,
+ );
+ let error = table.new_write_builder().new_write().err().unwrap();
+ assert!(error.to_string().contains("column default"));
+}
+
+#[tokio::test]
+async fn file_rolls_at_target_row_count_even_inside_one_batch() {
+ let table = memory_table("format_roll_rows", true,
&[("target-file-row-num", "2")]);
+ append(
+ &table,
+ &batch(&[("a", 1), ("a", 2), ("a", 3), ("a", 4), ("a", 5)]),
+ )
+ .await;
+ assert_eq!(ids(&table).await, [1, 2, 3, 4, 5]);
+ assert_eq!(visible_files(&table, "dt=a").await.len(), 3);
+}
+
+#[tokio::test]
+async fn overwrite_replaces_only_written_partitions_by_default() {
+ let table = memory_table("format_overwrite_dynamic", true, &[]);
+ append(&table, &batch(&[("a", 1), ("b", 2)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 7)])).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ builder
+ .new_commit()
+ .overwrite(messages, None)
+ .await
+ .unwrap();
+ assert_eq!(ids(&table).await, [2, 7]);
+ assert_eq!(visible_files(&table, "dt=a").await.len(), 1);
+}
+
+#[tokio::test]
+async fn static_partition_overwrite_without_output_clears_partition() {
+ let table = memory_table("format_overwrite_empty", true, &[]);
+ append(&table, &batch(&[("a", 1), ("b", 2)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ builder
+ .new_commit()
+ .overwrite(
+ Vec::new(),
+ Some(HashMap::from([(
+ "dt".to_string(),
+ Some(crate::spec::Datum::String("a".into())),
+ )])),
+ )
+ .await
+ .unwrap();
+ assert_eq!(ids(&table).await, [2]);
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+}
+
+#[tokio::test]
+async fn prepared_files_are_hidden_until_commit_and_abort_discards_them() {
+ let table = memory_table("format_abort_prepared", true, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&batch(&[("a", 1), ("a", 2)]))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ let staged = &messages[0].format_file.as_ref().unwrap().staged_path;
+ assert!(table.file_io().exists(staged).await.unwrap());
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+ assert!(table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+ builder.new_commit().abort(&messages).await.unwrap();
+ assert!(!table.file_io().exists(staged).await.unwrap());
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+}
+
+#[tokio::test]
+async fn closing_a_writer_discards_pending_data_without_publishing() {
+ let table = memory_table("format_close_pending", true, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 1)])).await.unwrap();
+ write.close().await;
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+ assert!(table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+}
+
+#[tokio::test]
+async fn rejected_batch_does_not_stage_or_publish_a_file() {
+ let table = memory_table("format_rejected_batch", true, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ let wrong = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ Field::new("dt", ArrowType::Utf8, true),
+ Field::new("id", ArrowType::Utf8, true),
+ ])),
+ vec![
+ Arc::new(StringArray::from(vec!["a"])),
+ Arc::new(StringArray::from(vec!["invalid"])),
+ ],
+ )
+ .unwrap();
+ let error = write.write_arrow_batch(&wrong).await.err().unwrap();
+ assert!(error.to_string().contains("expects"));
+ assert!(write.prepare_commit().await.is_err());
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+}
+
+#[tokio::test]
+async fn one_writer_can_prepare_more_than_one_append() {
+ let table = memory_table("format_writer_reuse", true, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 1)])).await.unwrap();
+ builder
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ write
+ .write_arrow_batch(&batch(&[("a", 2), ("b", 3)]))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(write.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ assert_eq!(ids(&table).await, [1, 2, 3]);
+ assert_eq!(visible_files(&table, "dt=a").await.len(), 2);
+ assert_eq!(visible_files(&table, "dt=b").await.len(), 1);
+}
+
+#[tokio::test]
+async fn value_only_partition_path_is_written_and_read() {
+ let table = memory_table(
+ "format_value_only_write",
+ true,
+ &[("format-table.partition-path-only-value", "true")],
+ );
+ append(&table, &batch(&[("a", 1), ("b", 2)])).await;
+ assert_eq!(ids(&table).await, [1, 2]);
+ assert_eq!(visible_files(&table, "a").await.len(), 1);
+ assert_eq!(visible_files(&table, "b").await.len(), 1);
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+}
+
+#[tokio::test]
+async fn overwrite_without_dynamic_partition_mode_replaces_all_partitions() {
+ let table = memory_table(
+ "format_overwrite_all",
+ true,
+ &[("dynamic-partition-overwrite", "false")],
+ );
+ append(&table, &batch(&[("a", 1), ("b", 2)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 7)])).await.unwrap();
+ builder
+ .new_commit()
+ .overwrite(write.prepare_commit().await.unwrap(), None)
+ .await
+ .unwrap();
+ assert_eq!(ids(&table).await, [7]);
+ assert!(visible_files(&table, "dt=b").await.is_empty());
+}
+
+#[tokio::test]
+async fn target_collision_is_rejected_before_any_overwrite_cleanup() {
+ let table = memory_table("format_target_collision", true, &[]);
+ append(&table, &batch(&[("a", 1)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 2)])).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ let file = messages[0].format_file.as_ref().unwrap();
+ table
+ .file_io()
+ .copy_file_streaming(&file.staged_path, &file.target_path)
+ .await
+ .unwrap();
+ let old_files = visible_files(&table, "dt=a").await;
+ let error = builder
+ .new_commit()
+ .overwrite(messages.clone(), None)
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("target already exists"));
+ assert_eq!(visible_files(&table, "dt=a").await, old_files);
+ builder.new_commit().abort(&messages).await.unwrap();
+ assert_eq!(visible_files(&table, "dt=a").await, old_files);
+}
+
+#[tokio::test]
+async fn missing_staged_file_cannot_erase_an_overwritten_partition() {
+ let table = memory_table("format_missing_stage", true, &[]);
+ append(&table, &batch(&[("a", 1)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write.write_arrow_batch(&batch(&[("a", 2)])).await.unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ let staged = &messages[0].format_file.as_ref().unwrap().staged_path;
+ table.file_io().delete_file(staged).await.unwrap();
+ let error = builder
+ .new_commit()
+ .overwrite(messages, None)
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("is missing"));
+ assert_eq!(ids(&table).await, [1]);
+}
+
+#[tokio::test]
+async fn format_file_message_refuses_snapshot_wire_serialization() {
+ let table = memory_table("format_message_serialize", false, &[]);
+ let builder = table.new_write_builder();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&unpartitioned_batch(&[1]))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ let error = messages[0].serialize().err().unwrap();
+ assert!(error.to_string().contains("different Java serializer"));
+ builder.new_commit().abort(&messages).await.unwrap();
+}
+
+#[tokio::test]
+async fn data_file_name_honors_prefix_and_compression_suffix() {
+ let table = memory_table(
+ "format_file_name",
+ true,
+ &[
+ ("data-file.prefix", "event-"),
+ ("file.suffix.include.compression", "true"),
+ ],
+ );
+ append(&table, &batch(&[("a", 1)])).await;
+ let files = visible_files(&table, "dt=a").await;
+ assert_eq!(files.len(), 1);
+ let name = files[0].rsplit('/').next().unwrap();
+ assert!(name.starts_with("event-"), "{name}");
+ assert!(name.ends_with(".snappy.parquet"), "{name}");
+ assert_eq!(ids(&table).await, [1]);
+}
+
+#[tokio::test]
+async fn static_prefix_overwrite_removes_only_matching_descendants() {
+ let table = two_partition_table("format_static_prefix");
+ append(
+ &table,
+ &two_partition_batch(&[("a", "00", 1), ("a", "01", 2), ("b", "00",
3)]),
+ )
+ .await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&two_partition_batch(&[("a", "01", 7)]))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .overwrite(
+ write.prepare_commit().await.unwrap(),
+ Some(HashMap::from([(
+ "dt".to_string(),
+ Some(crate::spec::Datum::String("a".into())),
+ )])),
+ )
+ .await
+ .unwrap();
+ assert_eq!(ids(&table).await, [3, 7]);
+ assert!(visible_files(&table, "dt=a/hour=00").await.is_empty());
+ assert_eq!(visible_files(&table, "dt=a/hour=01").await.len(), 1);
+ assert_eq!(visible_files(&table, "dt=b/hour=00").await.len(), 1);
+}
+
+#[tokio::test]
+async fn empty_static_overwrite_creates_the_selected_partition_directory() {
+ let table = two_partition_table("format_static_empty");
+ let prefix = HashMap::from([
+ (
+ "dt".to_string(),
+ Some(crate::spec::Datum::String("new".into())),
+ ),
+ (
+ "hour".to_string(),
+ Some(crate::spec::Datum::String("09".into())),
+ ),
+ ]);
+ table
+ .new_write_builder()
+ .with_overwrite()
+ .new_commit()
+ .overwrite(Vec::new(), Some(prefix))
+ .await
+ .unwrap();
+ assert!(table
+ .file_io()
+ .exists_dir("memory:/format_static_empty/dt=new/hour=09")
+ .await
+ .unwrap());
+ assert!(ids(&table).await.is_empty());
+}
+
+#[tokio::test]
+async fn output_outside_static_prefix_is_rejected_before_deleting_old_files() {
+ let table = two_partition_table("format_static_mismatch");
+ append(&table, &two_partition_batch(&[("a", "00", 1)])).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut write = builder.new_write().unwrap();
+ write
+ .write_arrow_batch(&two_partition_batch(&[("b", "00", 2)]))
+ .await
+ .unwrap();
+ let messages = write.prepare_commit().await.unwrap();
+ let error = builder
+ .new_commit()
+ .overwrite(
+ messages.clone(),
+ Some(HashMap::from([(
+ "dt".to_string(),
+ Some(crate::spec::Datum::String("a".into())),
+ )])),
+ )
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("outside the static overwrite"));
+ assert_eq!(ids(&table).await, [1]);
+ builder.new_commit().abort(&messages).await.unwrap();
+}
+
+#[tokio::test]
+async fn static_partition_must_be_a_leading_prefix() {
+ let table = two_partition_table("format_static_gap");
+ let error = table
+ .new_write_builder()
+ .with_overwrite()
+ .new_commit()
+ .overwrite(
+ Vec::new(),
+ Some(HashMap::from([(
+ "hour".to_string(),
+ Some(crate::spec::Datum::String("00".into())),
+ )])),
+ )
+ .await
+ .err()
+ .unwrap();
+ assert!(error.to_string().contains("lacks its leading partition"));
+ assert!(ids(&table).await.is_empty());
+}
+
+#[tokio::test]
+async fn physical_parquet_file_omits_partition_columns() {
+ let table = two_partition_table("format_physical_columns");
+ append(&table, &two_partition_batch(&[("a", "00", 7)])).await;
+ let files = visible_files(&table, "dt=a/hour=00").await;
+ assert_eq!(files.len(), 1);
+ let bytes = table
+ .file_io()
+ .new_input(&files[0])
+ .unwrap()
+ .read()
+ .await
+ .unwrap();
+ let reader =
+
parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(bytes).unwrap();
+ let fields = reader.schema().fields();
+ assert_eq!(fields.len(), 1);
+ assert_eq!(fields[0].name(), "id");
+ assert_eq!(ids(&table).await, [7]);
+}
+
+#[tokio::test]
+async fn row_format_round_trips_through_native_read() {
+ for format in ["row"] {
+ let name = format!("format_write_{format}");
+ let table = memory_table(&name, true, &[("file.format", format)]);
+ append(&table, &batch(&[("a", 1), ("b", 2), ("a", 3)])).await;
+ assert_eq!(ids(&table).await, [1, 2, 3], "format={format}");
+ assert_eq!(
+ visible_files(&table, "dt=a")
+ .await
+ .iter()
+ .filter(|path| path.ends_with(&format!(".{format}")))
+ .count(),
+ 1,
+ "format={format}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn null_partition_uses_the_configured_default_directory() {
+ let table = memory_table(
+ "format_null_partition",
+ true,
+ &[("partition.default-name", "_empty_")],
+ );
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ Field::new("dt", ArrowType::Utf8, true),
+ Field::new("id", ArrowType::Int32, true),
+ ])),
+ vec![
+ Arc::new(StringArray::from(vec![None::<&str>, Some("a")])),
+ Arc::new(Int32Array::from(vec![1, 2])),
+ ],
+ )
+ .unwrap();
+ append(&table, &batch).await;
+ assert_eq!(ids(&table).await, [1, 2]);
+ assert_eq!(visible_files(&table, "dt=_empty_").await.len(), 1);
+ assert_eq!(visible_files(&table, "dt=a").await.len(), 1);
+}
+
+#[test]
+fn invalid_target_row_count_is_rejected_before_a_writer_is_opened() {
+ let table = memory_table(
+ "format_bad_row_target",
+ false,
+ &[("target-file-row-num", "0")],
+ );
+ let error = table.new_write_builder().new_write().err().unwrap();
+ assert!(error
+ .to_string()
+ .contains("target-file-row-num must be positive"));
+}
+
+#[test]
+fn unsupported_format_is_rejected_before_a_writer_is_opened() {
+ let table = memory_table(
+ "format_unsupported_write",
+ false,
+ &[("file.format", "json")],
+ );
+ let error = table.new_write_builder().new_write().err().unwrap();
+ assert!(error.to_string().contains("not supported"));
+}
+
+#[test]
+fn readable_but_unwritable_formats_are_rejected_before_staging() {
+ for format in ["avro", "orc", "mosaic"] {
+ let table = memory_table(
+ &format!("format_no_{format}_writer"),
+ false,
+ &[("file.format", format)],
+ );
+ let error = table.new_write_builder().new_write().err().unwrap();
+ assert!(
+ error
+ .to_string()
+ .contains("can be read but cannot be written"),
+ "format={format}, error={error}"
+ );
+ }
+}
diff --git a/crates/paimon/src/table/format_table_writer.rs
b/crates/paimon/src/table/format_table_writer.rs
new file mode 100644
index 00000000..8490a6f9
--- /dev/null
+++ b/crates/paimon/src/table/format_table_writer.rs
@@ -0,0 +1,475 @@
+// 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.
+
+//! File writer for a Format Table. Java's `FormatTableWrite` puts partition
columns in
+//! the directory name and writes only the remaining columns to each data file.
+//! Files stay below a hidden staging directory until `FormatTableCommit`
publishes them.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+use arrow_array::RecordBatch;
+use arrow_schema::{Field, Schema as ArrowSchema};
+
+use super::commit_message::{CommitMessage, FormatFileCommit};
+use super::format_partition::FormatTablePartitionPaths;
+use super::format_table_scan::supported_format_table_extension;
+use super::table_write::take_rows;
+use super::Table;
+use crate::arrow::build_target_arrow_schema;
+use crate::arrow::format::{create_format_writer, with_write_resources,
FormatFileWriter};
+use crate::resource::ResourceContext;
+use crate::spec::{BinaryRow, CoreOptions, DataField};
+use crate::Result;
+
+struct OpenFile {
+ writer: Box<dyn FormatFileWriter>,
+ staged_path: String,
+ target_path: String,
+ record_count: i64,
+}
+
+struct PartitionWriter {
+ spec: HashMap<String, String>,
+ directory: String,
+ open: Option<OpenFile>,
+}
+
+/// Mirrors the file side of Java `FormatTableWrite` and
+/// `FormatTableRollingFileWriter`. The table schema is the input schema; a
+/// file's schema omits partition columns. A failed write owns and deletes its
+/// staged files, while prepared files are transferred to the committer.
+pub(crate) struct FormatTableWriter {
+ table: Table,
+ full_schema: Arc<ArrowSchema>,
+ file_schema: Arc<ArrowSchema>,
+ file_fields: Vec<DataField>,
+ partition_indices: Vec<usize>,
+ data_indices: Vec<usize>,
+ partition_paths: FormatTablePartitionPaths,
+ table_path: String,
+ data_file_prefix: String,
+ extension: String,
+ compression: String,
+ zstd_level: i32,
+ target_file_size: i64,
+ target_file_rows: i64,
+ resources: Option<ResourceContext>,
+ staging_root: String,
+ writers: HashMap<Vec<u8>, PartitionWriter>,
+ prepared: Vec<CommitMessage>,
+ owned_staging: HashSet<String>,
+ failed: bool,
+}
+
+impl FormatTableWriter {
+ pub(crate) fn set_resources(&mut self, resources: ResourceContext) {
+ self.resources = Some(resources);
+ }
+
+ pub(crate) fn new(table: &Table, resources: Option<ResourceContext>) ->
Result<Self> {
+ table.ensure_not_branch_reference_for_write()?;
+ let schema = table.schema();
+ if let Some(field) = schema
+ .fields()
+ .iter()
+ .find(|field| field.default_value().is_some())
+ {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Format Table column default for '{}' is not supported by
the Rust writer",
+ field.name()
+ ),
+ });
+ }
+ let options = CoreOptions::new(schema.options());
+ let format = options.file_format();
+ let extension = supported_format_table_extension(&format)?.to_string();
+ // Readers cover more external file types than native writers. Reject
+ // unsupported formats before staging any files, rather than failing
+ // after the first RecordBatch has been routed.
+ match format.as_str() {
+ "parquet" | "row" => {}
+ #[cfg(feature = "vortex")]
+ "vortex" => {}
+ _ => {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Format Table file.format '{format}' can be read but
cannot be written by the Rust client"
+ ),
+ });
+ }
+ }
+ let full_schema = build_target_arrow_schema(schema.fields())?;
+ let partition_indices = schema
+ .partition_keys()
+ .iter()
+ .map(|key| {
+ schema
+ .fields()
+ .iter()
+ .position(|field| field.name() == key)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("Unknown Format Table partition
column '{key}'"),
+ source: None,
+ })
+ })
+ .collect::<Result<Vec<_>>>()?;
+ let data_indices = (0..schema.fields().len())
+ .filter(|index| !partition_indices.contains(index))
+ .collect::<Vec<_>>();
+ let file_fields = data_indices
+ .iter()
+ .map(|&index| schema.fields()[index].clone())
+ .collect::<Vec<_>>();
+ let file_schema = Arc::new(ArrowSchema::new(
+ data_indices
+ .iter()
+ .map(|&index| Arc::new(Field::clone(full_schema.field(index))))
+ .collect::<Vec<_>>(),
+ ));
+ let target_file_rows = schema
+ .options()
+ .get("target-file-row-num")
+ .and_then(|value| value.parse::<i64>().ok())
+ .unwrap_or(i64::MAX);
+ if target_file_rows <= 0 {
+ return Err(crate::Error::ConfigInvalid {
+ message: "target-file-row-num must be positive".into(),
+ });
+ }
+ let compression = format_table_compression(schema.options(), &format);
+ let extension = if schema
+ .options()
+ .get("file.suffix.include.compression")
+ .is_some_and(|value| value.eq_ignore_ascii_case("true"))
+ && !matches!(compression.as_str(), "" | "none")
+ {
+ format!(".{compression}{extension}")
+ } else {
+ extension
+ };
+ let table_path = options
+ .path()
+ .unwrap_or_else(|| table.location())
+ .trim_end_matches('/')
+ .to_string();
+ let staging_root = format!("{table_path}/_temporary/{}",
uuid::Uuid::new_v4());
+ Ok(Self {
+ table: table.clone(),
+ full_schema,
+ file_schema,
+ file_fields,
+ partition_indices,
+ data_indices,
+ partition_paths: FormatTablePartitionPaths::new(
+ schema.partition_keys().iter().cloned(),
+ options.format_table_partition_only_value_in_path(),
+ ),
+ table_path,
+ data_file_prefix: options.data_file_prefix().to_string(),
+ extension,
+ compression,
+ zstd_level: options.file_compression_zstd_level(),
+ target_file_size: options.target_file_size(),
+ target_file_rows,
+ resources,
+ staging_root,
+ writers: HashMap::new(),
+ prepared: Vec::new(),
+ owned_staging: HashSet::new(),
+ failed: false,
+ })
+ }
+
+ pub(crate) async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
+ if self.failed {
+ return Err(crate::Error::DataInvalid {
+ message: "Format Table writer failed; create a new
writer".into(),
+ source: None,
+ });
+ }
+ let result = self.write_inner(batch).await;
+ if result.is_err() {
+ self.failed = true;
+ self.close().await;
+ }
+ result
+ }
+
+ async fn write_inner(&mut self, batch: &RecordBatch) -> Result<()> {
+ self.check_schema(batch)?;
+ if batch.num_rows() == 0 {
+ return Ok(());
+ }
+
+ // Validate all non-null fields before opening a file. Java validates
the
+ // input row before default-value replacement and partition extraction.
+ for (field_index, field) in
self.full_schema.fields().iter().enumerate() {
+ if !field.is_nullable() && batch.column(field_index).null_count()
> 0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot write null to non-null
column({})", field.name()),
+ source: None,
+ });
+ }
+ }
+
+ let mut groups: HashMap<Vec<u8>, Vec<usize>> = HashMap::new();
+ let mut specs = HashMap::new();
+ let partition_fields = self.table.schema().partition_fields();
+ let options = CoreOptions::new(self.table.schema().options());
+ let computer = crate::spec::PartitionComputer::new(
+ self.table.schema().partition_keys(),
+ self.table.schema().fields(),
+ options.partition_default_name(),
+ options.legacy_partition_name(),
+ )?;
+ for row_index in 0..batch.num_rows() {
+ let partition = BinaryRow::from_arrow(
+ batch,
+ row_index,
+ &self.partition_indices,
+ &partition_fields,
+ )?;
+ let key = partition.to_serialized_bytes();
+ groups.entry(key.clone()).or_default().push(row_index);
+ if let std::collections::hash_map::Entry::Vacant(entry) =
specs.entry(key) {
+ entry.insert(
+ computer
+ .generate_part_values(&partition)?
+ .into_iter()
+ .collect(),
+ );
+ }
+ }
+
+ for (key, rows) in groups {
+ let input = take_rows(batch, &rows)?;
+ let file_batch = self.project_data_columns(&input)?;
+ if !self.writers.contains_key(&key) {
+ let spec = specs.remove(&key).unwrap();
+ let relative = self.partition_paths.relative_path(&spec)?;
+ let directory = if relative.is_empty() {
+ self.table_path.clone()
+ } else {
+ format!("{}/{relative}", self.table_path)
+ };
+ self.writers.insert(
+ key.clone(),
+ PartitionWriter {
+ spec,
+ directory,
+ open: None,
+ },
+ );
+ }
+ self.write_partition(&key, &file_batch).await?;
+ }
+ Ok(())
+ }
+
+ fn check_schema(&self, batch: &RecordBatch) -> Result<()> {
+ let expected = self.full_schema.fields();
+ let actual = batch.schema();
+ if actual.fields().len() != expected.len() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Format Table write expects {} columns, got {}",
+ expected.len(),
+ actual.fields().len()
+ ),
+ source: None,
+ });
+ }
+ for (index, field) in expected.iter().enumerate() {
+ let given = actual.field(index);
+ if field.name() != given.name() || field.data_type() !=
given.data_type() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Format Table column {} expects {}: {:?}, got {}:
{:?}",
+ index,
+ field.name(),
+ field.data_type(),
+ given.name(),
+ given.data_type()
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+ }
+
+ fn project_data_columns(&self, batch: &RecordBatch) -> Result<RecordBatch>
{
+ RecordBatch::try_new(
+ self.file_schema.clone(),
+ self.data_indices
+ .iter()
+ .map(|&index| batch.column(index).clone())
+ .collect(),
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!("Cannot project Format Table data columns:
{error}"),
+ source: Some(Box::new(error)),
+ })
+ }
+
+ async fn write_partition(&mut self, key: &[u8], batch: &RecordBatch) ->
Result<()> {
+ let mut remaining = batch.clone();
+ while remaining.num_rows() > 0 {
+ if self.writers.get(key).unwrap().open.is_none() {
+ self.open_file(key).await?;
+ }
+ let current_rows = self
+ .writers
+ .get(key)
+ .unwrap()
+ .open
+ .as_ref()
+ .unwrap()
+ .record_count;
+ let available = (self.target_file_rows - current_rows) as usize;
+ let rows = available.min(remaining.num_rows());
+ let chunk = remaining.slice(0, rows);
+ self.writers
+ .get_mut(key)
+ .unwrap()
+ .open
+ .as_mut()
+ .unwrap()
+ .writer
+ .write(&chunk)
+ .await?;
+ remaining = remaining.slice(rows, remaining.num_rows() - rows);
+ let open =
self.writers.get_mut(key).unwrap().open.as_mut().unwrap();
+ open.record_count += rows as i64;
+ let should_roll = open.record_count >= self.target_file_rows
+ || open.writer.num_bytes() as i64 >= self.target_file_size;
+ if should_roll {
+ self.close_file(key).await?;
+ }
+ }
+ Ok(())
+ }
+
+ async fn open_file(&mut self, key: &[u8]) -> Result<()> {
+ let partition = self.writers.get(key).unwrap();
+ let file_name = format!(
+ "{}{}{}",
+ self.data_file_prefix,
+ uuid::Uuid::new_v4(),
+ self.extension
+ );
+ let target_path = format!("{}/{file_name}", partition.directory);
+ let staged_path = format!("{}/{}", self.staging_root, file_name);
+ self.table
+ .file_io()
+ .mkdirs(&format!("{}/", self.staging_root))
+ .await?;
+ self.owned_staging.insert(staged_path.clone());
+ let output = self.table.file_io().new_output(&staged_path)?;
+ let writer = create_format_writer(
+ &output,
+ self.file_schema.clone(),
+ &self.compression,
+ self.zstd_level,
+ Some(self.table.file_io().clone()),
+ Some(&self.file_fields),
+ Some(self.table.schema().options()),
+ )
+ .await?;
+ self.writers.get_mut(key).unwrap().open = Some(OpenFile {
+ writer: with_write_resources(writer, self.resources.as_ref()),
+ staged_path,
+ target_path,
+ record_count: 0,
+ });
+ Ok(())
+ }
+
+ async fn close_file(&mut self, key: &[u8]) -> Result<()> {
+ let partition = self.writers.get_mut(key).unwrap();
+ let Some(file) = partition.open.take() else {
+ return Ok(());
+ };
+ file.writer.close().await?;
+ let size = self
+ .table
+ .file_io()
+ .get_status(&file.staged_path)
+ .await?
+ .size as i64;
+ let mut message = CommitMessage::new(key.to_vec(), 0, Vec::new());
+ message.format_file = Some(FormatFileCommit {
+ staged_path: file.staged_path.clone(),
+ target_path: file.target_path,
+ partition: partition.spec.clone(),
+ record_count: file.record_count,
+ file_size: size,
+ });
+ self.owned_staging.remove(&file.staged_path);
+ self.prepared.push(message);
+ Ok(())
+ }
+
+ pub(crate) async fn prepare_commit(&mut self) ->
Result<Vec<CommitMessage>> {
+ if self.failed {
+ return Err(crate::Error::DataInvalid {
+ message: "Format Table writer failed; create a new
writer".into(),
+ source: None,
+ });
+ }
+ let keys = self.writers.keys().cloned().collect::<Vec<_>>();
+ for key in &keys {
+ if let Err(error) = self.close_file(key).await {
+ self.failed = true;
+ self.close().await;
+ return Err(error);
+ }
+ }
+ self.writers.clear();
+ Ok(std::mem::take(&mut self.prepared))
+ }
+
+ pub(crate) async fn close(&mut self) {
+ self.writers.clear();
+ for path in self.owned_staging.drain() {
+ let _ = self.table.file_io().delete_file(&path).await;
+ }
+ for message in self.prepared.drain(..) {
+ if let Some(file) = message.format_file {
+ let _ =
self.table.file_io().delete_file(&file.staged_path).await;
+ }
+ }
+ }
+}
+
+fn format_table_compression(options: &HashMap<String, String>, format: &str)
-> String {
+ options
+ .get("file.compression")
+ .or_else(|| options.get("format-table.file.compression"))
+ .or_else(|| options.get("compression"))
+ .cloned()
+ .unwrap_or_else(|| {
+ match format {
+ "parquet" => "snappy",
+ "orc" | "avro" | "mosaic" => "zstd",
+ _ => "none",
+ }
+ .to_string()
+ })
+}
diff --git a/crates/paimon/src/table/format_write_builder.rs
b/crates/paimon/src/table/format_write_builder.rs
index fc1764c1..161603ce 100644
--- a/crates/paimon/src/table/format_write_builder.rs
+++ b/crates/paimon/src/table/format_write_builder.rs
@@ -25,6 +25,8 @@ use uuid::Uuid;
pub(crate) struct FormatWriteBuilder<'a> {
table: &'a Table,
commit_user: String,
+ overwrite: bool,
+ resources: Option<ResourceContext>,
}
impl<'a> FormatWriteBuilder<'a> {
@@ -32,6 +34,8 @@ impl<'a> FormatWriteBuilder<'a> {
Self {
table,
commit_user: Uuid::new_v4().to_string(),
+ overwrite: false,
+ resources: None,
}
}
@@ -49,11 +53,13 @@ impl<'a> FormatWriteBuilder<'a> {
Ok(self)
}
- pub(crate) fn with_overwrite(self) -> Self {
+ pub(crate) fn with_overwrite(mut self) -> Self {
+ self.overwrite = true;
self
}
- pub(crate) fn with_resources(self, _resources: ResourceContext) -> Self {
+ pub(crate) fn with_resources(mut self, resources: ResourceContext) -> Self
{
+ self.resources = Some(resources);
self
}
@@ -67,9 +73,12 @@ impl<'a> FormatWriteBuilder<'a> {
}
pub(crate) fn new_write(&self) -> crate::Result<TableWrite> {
- Err(crate::Error::Unsupported {
- message: "Writing format tables is not supported by the Rust
client yet".to_string(),
- })
+ TableWrite::new_format(
+ self.table,
+ self.commit_user.clone(),
+ self.resources.clone(),
+ self.overwrite,
+ )
}
pub(crate) fn new_update(&self, _update_columns: Vec<String>) ->
crate::Result<TableUpdate> {
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index f536f3b5..231fdd86 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -49,8 +49,12 @@ mod format_partition;
mod format_partition_stats;
mod format_partition_truncate;
mod format_read_builder;
+mod format_table_commit;
mod format_table_read;
mod format_table_scan;
+#[cfg(test)]
+mod format_table_write_tests;
+mod format_table_writer;
mod format_write_builder;
#[cfg(feature = "fulltext")]
mod full_text_index_adapter;
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 183f2926..3e4b9c08 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -31,6 +31,7 @@ use crate::spec::{
EMPTY_SERIALIZED_ROW, MANIFEST_ENTRY_SCHEMA, POSTPONE_BUCKET,
};
use crate::table::commit_message::CommitMessage;
+use crate::table::format_table_commit::FormatTableCommit;
use crate::table::global_index_build_common::same_extra_field_ids;
use crate::table::index_file_path::committed_index_file_path;
use crate::table::partition_filter::PartitionFilter;
@@ -318,6 +319,11 @@ impl TableCommit {
commit_messages: Vec<CommitMessage>,
commit_identifier: i64,
) -> Result<()> {
+ if self.table.is_format_table() {
+ return FormatTableCommit::new(&self.table)
+ .append(&commit_messages)
+ .await;
+ }
self.commit_with_identifier_impl(commit_messages, commit_identifier,
false)
.await
}
@@ -342,6 +348,11 @@ impl TableCommit {
commit_identifier: i64,
filter_committed: bool,
) -> Result<()> {
+ if self.table.is_format_table() {
+ return Err(crate::Error::Unsupported {
+ message: "Format Table commits have no checkpoint
identifiers".into(),
+ });
+ }
// A commit validates against the existing snapshot.
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
@@ -446,6 +457,11 @@ impl TableCommit {
commit_messages: Vec<CommitMessage>,
static_partitions: Option<HashMap<String, Option<Datum>>>,
) -> Result<()> {
+ if self.table.is_format_table() {
+ return FormatTableCommit::new(&self.table)
+ .overwrite(&commit_messages, static_partitions.as_ref())
+ .await;
+ }
self.overwrite_impl(
commit_messages,
static_partitions,
@@ -465,6 +481,11 @@ impl TableCommit {
static_partitions: Option<HashMap<String, Option<Datum>>>,
commit_identifier: i64,
) -> Result<()> {
+ if self.table.is_format_table() {
+ return Err(crate::Error::Unsupported {
+ message: "Format Table overwrites have no checkpoint
identifiers".into(),
+ });
+ }
self.overwrite_impl(commit_messages, static_partitions,
commit_identifier, true)
.await
}
@@ -862,6 +883,11 @@ impl TableCommit {
/// files or storage errors are ignored so abort cleanup never masks the
/// original write failure.
pub async fn abort(&self, commit_messages: &[CommitMessage]) -> Result<()>
{
+ if self.table.is_format_table() {
+ return FormatTableCommit::new(&self.table)
+ .abort(commit_messages)
+ .await;
+ }
CoreOptions::new(self.table.schema().options())
.ensure_type_paimon_served(&self.table.identifier().full_name())?;
self.table.ensure_not_branch_reference_for_write()?;
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 01567d67..9901072c 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -39,6 +39,7 @@ use crate::table::commit_message::CommitMessage;
use crate::table::data_file_index_writer::FileIndexOptions;
use crate::table::data_file_writer::DataFileWriter;
use
crate::table::dedicated_format_file_writer::AppendDedicatedFormatFileWriter;
+use crate::table::format_table_writer::FormatTableWriter;
use crate::table::kv_file_writer::{KeyValueFileWriter, KeyValueWriteConfig};
use crate::table::partition_filter::PartitionFilter;
use crate::table::postpone_file_writer::{PostponeFileWriter,
PostponeWriteConfig};
@@ -123,6 +124,8 @@ impl FileWriter {
///
/// Reference: [pypaimon
BatchTableWrite](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/write/table_write.py)
pub struct TableWrite {
+ // Keep the Format Table state off ordinary Paimon write futures' stacks.
+ format_writer: Option<Box<FormatTableWriter>>,
table: Table,
write_schema: Arc<arrow_schema::Schema>,
partition_writers: HashMap<PartitionBucketKey, FileWriter>,
@@ -172,6 +175,69 @@ pub struct TableWrite {
}
impl TableWrite {
+ pub(crate) fn new_format(
+ table: &Table,
+ commit_user: String,
+ resources: Option<ResourceContext>,
+ overwrite: bool,
+ ) -> Result<Self> {
+ let format_writer = FormatTableWriter::new(table, resources.clone())?;
+ let schema = table.schema();
+ let options = CoreOptions::new(schema.options());
+ // Format Tables do not use Paimon buckets, indexes, changelogs or
+ // snapshots. Build their public TableWrite wrapper without running
+ // Paimon-only option validation or constructing stateful assigners.
+ Ok(Self {
+ format_writer: Some(Box::new(format_writer)),
+ table: table.clone(),
+ write_schema: build_target_arrow_schema(schema.fields())?,
+ partition_writers: HashMap::new(),
+ partition_computer: PartitionComputer::new(
+ schema.partition_keys(),
+ schema.fields(),
+ options.partition_default_name(),
+ options.legacy_partition_name(),
+ )?,
+ partition_keys: schema.partition_keys().to_vec(),
+ schema_id: schema.id(),
+ target_file_size: 0,
+ blob_target_file_size: 0,
+ vector_target_file_size: 0,
+ file_compression: String::new(),
+ file_compression_zstd_level: 0,
+ write_buffer_size: 0,
+ file_format: String::new(),
+ data_file_prefix: String::new(),
+ primary_key_indices: Vec::new(),
+ primary_key_types: Vec::new(),
+ sequence_field_indices: Vec::new(),
+ merge_engine: MergeEngine::Deduplicate,
+ changelog_producer: ChangelogProducer::None,
+ changelog_file_prefix: String::new(),
+ changelog_file_format: String::new(),
+ changelog_file_compression: String::new(),
+ partition_seq_cache: HashMap::new(),
+ sequence_snapshot: None,
+ commit_user,
+ postpone_write_id: 0,
+ bucket_assigner:
BucketAssignerEnum::Constant(ConstantBucketAssigner::new(
+ Vec::new(),
+ 0,
+ )),
+ is_overwrite: overwrite,
+ blob_view_fields: HashSet::new(),
+ blob_inline_fields: HashSet::new(),
+ has_blob_fields: false,
+ vector_file_format: None,
+ has_dedicated_vector_fields: false,
+ row_kind_generator: None,
+ row_kind_filter: None,
+ file_index_options: None,
+ resources,
+ failed: false,
+ })
+ }
+
pub(crate) fn new(table: &Table, commit_user: String) ->
crate::Result<Self> {
// A dynamic-bucket write reads the persisted PK hash index; the rest
are
// refused too, since their commit is blocked anyway.
@@ -407,6 +473,7 @@ impl TableWrite {
}
Ok(Self {
+ format_writer: None,
table: table.clone(),
write_schema,
partition_writers: HashMap::new(),
@@ -525,12 +592,18 @@ impl TableWrite {
/// retained key-value batches and unflushed format-writer input. Call this
/// before the first write.
pub fn with_resources(mut self, resources: ResourceContext) -> Self {
+ if let Some(writer) = self.format_writer.as_mut() {
+ writer.set_resources(resources.clone());
+ }
self.resources = Some(resources);
self
}
/// Write an Arrow RecordBatch. Rows are routed to the correct partition
and bucket.
pub async fn write_arrow_batch(&mut self, batch: &RecordBatch) ->
Result<()> {
+ if let Some(writer) = self.format_writer.as_mut() {
+ return writer.write(batch).await;
+ }
self.ensure_active()?;
let Some(batch) = self.normalize_write_batch(batch)? else {
return Ok(());
@@ -921,6 +994,10 @@ impl TableWrite {
/// Close without preparing another commit, discarding only outstanding
output.
/// Files already returned by prepare_commit belong to the caller.
pub async fn close(&mut self) {
+ if let Some(writer) = self.format_writer.as_mut() {
+ writer.close().await;
+ return;
+ }
for (_, mut writer) in self.partition_writers.drain() {
writer.abort().await;
}
@@ -938,6 +1015,9 @@ impl TableWrite {
/// commits. (`sequence_snapshot` is pinned only by the postpone path,
which
/// forbids reuse, so it is left untouched.)
pub async fn prepare_commit(&mut self) -> Result<Vec<CommitMessage>> {
+ if let Some(writer) = self.format_writer.as_mut() {
+ return writer.prepare_commit().await;
+ }
self.ensure_active()?;
self.partition_seq_cache.clear();
let writers: Vec<(PartitionBucketKey, FileWriter)> =
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 3afa0ac0..893c61ca 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -72,6 +72,7 @@ struct MockState {
create_partitions_calls: Vec<(String, String, CreatePartitionsRequest)>,
drop_partitions_calls: Vec<(String, String, DropPartitionsRequest)>,
create_partitions_error_status: Option<StatusCode>,
+ create_partitions_statistics_error_status: Option<StatusCode>,
list_partitions_error_status: Option<StatusCode>,
permissions: Vec<PermissionAssignment>,
list_permissions_queries: Vec<HashMap<String, String>>,
@@ -1012,6 +1013,7 @@ impl RESTServer {
return (StatusCode::NOT_FOUND, Json(error)).into_response();
}
+ let statistics_error = inner.create_partitions_statistics_error_status;
let registered_partitions = inner.partitions.entry(key).or_default();
let has_conflict = request
.partition_specs
@@ -1033,12 +1035,28 @@ impl RESTServer {
return (StatusCode::CONFLICT, Json(error)).into_response();
}
- for spec in request.partition_specs {
- if !registered_partitions
+ for (index, spec) in request.partition_specs.into_iter().enumerate() {
+ let position = registered_partitions
.iter()
- .any(|partition| partition.spec == spec)
- {
+ .position(|partition| partition.spec == spec);
+ let partition = if let Some(position) = position {
+ &mut registered_partitions[position]
+ } else {
registered_partitions.push(partition_from_spec(spec));
+ registered_partitions.last_mut().unwrap()
+ };
+ if let Some(options) = request
+ .partition_options
+ .as_ref()
+ .and_then(|options| options.get(index))
+ {
+ partition.options = Some(options.clone());
+ }
+ }
+ if request.partition_statistics.is_some() {
+ if let Some(status) = statistics_error {
+ return (status, Json(json!({"message": "Statistics update
failed"})))
+ .into_response();
}
}
// As the catalog does: a negative field was never measured and leaves
the stored value
@@ -1743,6 +1761,14 @@ impl RESTServer {
self.inner.lock().unwrap().create_partitions_error_status = status;
}
+ /// Fail only statistics-bearing partition requests, after registration.
+ pub fn set_create_partitions_statistics_error_status(&self, status:
Option<StatusCode>) {
+ self.inner
+ .lock()
+ .unwrap()
+ .create_partitions_statistics_error_status = status;
+ }
+
/// Make the list-partitions endpoint return the given status.
pub fn set_list_partitions_error_status(&self, status: Option<StatusCode>)
{
self.inner.lock().unwrap().list_partitions_error_status = status;
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index d23309da..e2dd53ec 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1098,7 +1098,7 @@ async fn test_rest_catalog_reads_format_table() {
let table = ctx.catalog.get_table(&identifier).await.unwrap();
assert_eq!(table.location(), format_path);
assert_eq!(table.schema().options().get("path"), Some(&format_path));
- assert!(table.new_write_builder().new_write().is_err());
+ assert!(table.new_write_builder().new_write().is_ok());
let read_builder = table.new_read_builder();
let plan = read_builder.new_scan().plan().await.unwrap();