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 f107ce1f feat(core): support sparse raw Blob column updates (#1081)
f107ce1f is described below
commit f107ce1fbd5fef13975dc4176f69b00062e0dfff
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 10:00:59 2026 +0800
feat(core): support sparse raw Blob column updates (#1081)
---
.../python/python/pypaimon_rust/datafusion.pyi | 1 +
bindings/python/src/write.rs | 17 +-
crates/integrations/datafusion/tests/blob_tests.rs | 187 ++++-
crates/paimon/src/arrow/format/blob/writer.rs | 114 ++-
.../src/arrow/format/blob/writer_consumer_tests.rs | 60 +-
crates/paimon/src/table/data_evolution_blob.rs | 253 ++++++
crates/paimon/src/table/data_evolution_writer.rs | 361 +++++++--
crates/paimon/src/table/data_file_writer.rs | 28 +-
crates/paimon/src/table/table_update_by_row_id.rs | 13 +
crates/paimon/tests/blob_collection_write_test.rs | 14 -
crates/paimon/tests/blob_update_test.rs | 890 +++++++++++++++++++++
11 files changed, 1808 insertions(+), 130 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 65f7090f..af2b9f3b 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -320,6 +320,7 @@ class BatchTableUpdate:
...
class TableUpdateByRowId:
+ def _with_blob_uri_reader_factory(self, factory: Optional[Any]) -> None:
...
def update_columns(self, data: pyarrow.Table, column_names: List[str]) ->
List[CommitMessage]:
"""Stage one input table; return the cumulative messages to commit
once."""
...
diff --git a/bindings/python/src/write.rs b/bindings/python/src/write.rs
index ea65b1be..eec73110 100644
--- a/bindings/python/src/write.rs
+++ b/bindings/python/src/write.rs
@@ -877,6 +877,21 @@ pub struct PyTableUpdateByRowId {
#[pymethods]
impl PyTableUpdateByRowId {
+ fn _with_blob_uri_reader_factory(
+ &mut self,
+ py: Python<'_>,
+ factory: Option<Py<PyAny>>,
+ ) -> PyResult<()> {
+ let factory = factory
+ .map(|factory| {
+ PythonUriReaderFactory::new(factory, py)
+ .map(|factory| Arc::new(factory) as Arc<dyn
paimon::io::UriReaderFactory>)
+ })
+ .transpose()?;
+ self.inner.with_blob_uri_reader_factory(factory);
+ Ok(())
+ }
+
fn update_columns(
&mut self,
py: Python<'_>,
@@ -886,7 +901,7 @@ impl PyTableUpdateByRowId {
let batches = arrow_table_batches(data)?;
let messages = py
.detach(|| runtime().block_on(self.inner.update_columns(batches,
column_names)))
- .map_err(to_py_err)?;
+ .map_err(to_callback_error)?;
Ok(wrap_messages(
messages,
&self.table_location,
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs
b/crates/integrations/datafusion/tests/blob_tests.rs
index 8b2a9aed..3c5f44a3 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -21,7 +21,7 @@
mod common;
-use arrow_array::{Array, Int32Array, LargeBinaryArray, RecordBatch};
+use arrow_array::{Array, Int32Array, LargeBinaryArray, ListArray, RecordBatch};
use common::{assert_sql_error, create_sql_context, create_test_env, exec,
string_value};
use paimon::catalog::Identifier;
use paimon::spec::{BlobDescriptor, BlobViewStruct};
@@ -340,37 +340,200 @@ async fn
test_merge_into_updates_non_blob_on_raw_blob_table() {
);
}
-/// Reference: BlobTestBase "Blob: merge-into rejects updating raw-data BLOB
column"
+/// Reference: Java Spark BlobUpdateTestBase raw-data BLOB and NULL updates.
#[tokio::test]
-async fn test_merge_into_rejects_raw_blob_update() {
+async fn test_merge_into_updates_raw_blob_and_preserves_unmatched_rows() {
let (_tmp, sql_context) = setup(BLOB_TABLE_DDL).await;
exec(
&sql_context,
- "INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 'Alice',
X'4141')",
+ "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+ (1, 'Alice', X'4141'), (2, 'Bob', X'4242'), (3, 'Carol', X'4343'), \
+ (4, 'Dave', NULL), (5, 'Eve', NULL)",
)
.await;
exec(
&sql_context,
- "CREATE TEMPORARY TABLE paimon.test_db.src AS SELECT * FROM (VALUES
(1, X'4242')) AS t(id, picture)",
+ "CREATE TEMPORARY TABLE paimon.test_db.src AS SELECT * FROM \
+ (VALUES (1, X'4444'), (3, CAST(NULL AS BYTEA)), (5, X'4545'), (99,
X'4646')) \
+ AS t(id, picture)",
)
.await;
- let result = sql_context
- .sql(
+ exec(
+ &sql_context,
+ "MERGE INTO paimon.test_db.t t \
+ USING paimon.test_db.src s ON t.id = s.id \
+ WHEN MATCHED THEN UPDATE SET picture = s.picture",
+ )
+ .await;
+ assert_eq!(
+ query_id_name_picture(
+ &sql_context,
+ "SELECT id, name, picture FROM paimon.test_db.t"
+ )
+ .await,
+ vec![
+ (1, "Alice".into(), Some(b"DD".to_vec())),
+ (2, "Bob".into(), Some(b"BB".to_vec())),
+ (3, "Carol".into(), None),
+ (4, "Dave".into(), None),
+ (5, "Eve".into(), Some(b"EE".to_vec())),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn test_update_raw_blob_preserves_unmatched_rows() {
+ let (_tmp, sql_context) = setup(BLOB_TABLE_DDL).await;
+ exec(
+ &sql_context,
+ "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+ (1, 'Alice', X'4141'), (2, 'Bob', X'4242'), (3, 'Carol', X'4343')",
+ )
+ .await;
+
+ exec(
+ &sql_context,
+ "UPDATE paimon.test_db.t SET picture = X'4444', name = 'Updated' WHERE
id = 2",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "UPDATE paimon.test_db.t SET picture = NULL WHERE id = 3",
+ )
+ .await;
+ exec(
+ &sql_context,
+ "UPDATE paimon.test_db.t SET picture = X'4545' WHERE id = 99",
+ )
+ .await;
+ assert_eq!(
+ query_id_name_picture(
+ &sql_context,
+ "SELECT id, name, picture FROM paimon.test_db.t"
+ )
+ .await,
+ vec![
+ (1, "Alice".into(), Some(b"AA".to_vec())),
+ (2, "Updated".into(), Some(b"DD".to_vec())),
+ (3, "Carol".into(), None),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn test_merge_into_updates_raw_blob_collections() {
+ // Java Spark BlobUpdateTestBase also covers ARRAY<BLOB> and MAP<X, BLOB>.
+ for (blob_type, original, keep, updated, empty, projection) in [
+ (
+ "ARRAY<BLOB>",
+ "[X'4141']",
+ "[X'4242']",
+ "[X'4343', CAST(NULL AS BYTEA)]",
+ "CAST([] AS BYTEA[])",
+ "picture",
+ ),
+ (
+ "MAP(STRING, BLOB)",
+ "map(['old'], [X'4141'])",
+ "map(['keep'], [X'4242'])",
+ "map(['new', 'missing'], [X'4343', CAST(NULL AS BYTEA)])",
+ "map(CAST([] AS STRING[]), CAST([] AS BYTEA[]))",
+ "map_values(picture)",
+ ),
+ ] {
+ let (_tmp, sql_context) = setup(&format!(
+ "CREATE TABLE paimon.test_db.t (id INT, picture {blob_type}) WITH
(\
+ 'data-evolution.enabled' = 'true', 'row-tracking.enabled' =
'true')"
+ ))
+ .await;
+ exec(
+ &sql_context,
+ &format!(
+ "INSERT INTO paimon.test_db.t (id, picture) VALUES \
+ (1, {original}), (2, {keep}), (3, NULL), (4, {original})"
+ ),
+ )
+ .await;
+ exec(
+ &sql_context,
+ &format!(
+ "CREATE TABLE paimon.test_db.src (id INT, picture {blob_type})
WITH (\
+ 'data-evolution.enabled' = 'true', 'row-tracking.enabled'
= 'true')"
+ ),
+ )
+ .await;
+ exec(
+ &sql_context,
+ &format!(
+ "INSERT INTO paimon.test_db.src (id, picture) VALUES \
+ (1, {updated}), (3, {empty}), (4, NULL), (99, {original})"
+ ),
+ )
+ .await;
+ exec(
+ &sql_context,
"MERGE INTO paimon.test_db.t t \
USING paimon.test_db.src s ON t.id = s.id \
WHEN MATCHED THEN UPDATE SET picture = s.picture",
)
.await;
- assert!(
- result.is_err() || {
- let df = result.unwrap();
- df.collect().await.is_err()
+ let batches = sql_context
+ .sql(&format!(
+ "SELECT id, {projection} FROM paimon.test_db.t ORDER BY id"
+ ))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in &batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let lists = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ let values = (!lists.is_null(row)).then(|| {
+ let values = lists.value(row);
+ let blobs =
values.as_any().downcast_ref::<LargeBinaryArray>().unwrap();
+ blobs
+ .iter()
+ .map(|value| value.map(<[u8]>::to_vec))
+ .collect::<Vec<_>>()
+ });
+ rows.push((ids.value(row), values));
+ }
}
- );
+ assert_eq!(
+ rows,
+ vec![
+ (1, Some(vec![Some(b"CC".to_vec()), None])),
+ (2, Some(vec![Some(b"BB".to_vec())])),
+ (3, Some(vec![])),
+ (4, None),
+ ]
+ );
+ if blob_type.starts_with("MAP") {
+ assert_eq!(
+ query_id_name_picture(
+ &sql_context,
+ "SELECT id, 'map' AS name, picture['new'] FROM
paimon.test_db.t WHERE id = 1"
+ )
+ .await,
+ vec![(1, "map".into(), Some(b"CC".to_vec()))]
+ );
+ }
+ }
}
/// Reference: BlobTestBase "Blob: merge-into updates non-blob column on
descriptor blob table"
diff --git a/crates/paimon/src/arrow/format/blob/writer.rs
b/crates/paimon/src/arrow/format/blob/writer.rs
index 5219c5a2..197550f1 100644
--- a/crates/paimon/src/arrow/format/blob/writer.rs
+++ b/crates/paimon/src/arrow/format/blob/writer.rs
@@ -30,14 +30,23 @@ use crate::{Error, Result};
use arrow_array::{
Array, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Int16Array, Int32Array,
Int64Array, Int8Array, LargeBinaryArray, ListArray, MapArray, RecordBatch,
StringArray,
- Time32MillisecondArray,
+ StructArray, Time32MillisecondArray,
};
use async_trait::async_trait;
use bytes::Bytes;
use crc32fast::Hasher;
use std::collections::{HashMap, HashSet};
+use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
+/// Positions are relative to the logical update range, across rolled files.
+/// Java's BlobPlaceholder has index length -2; an explicit NULL has length -1.
+#[derive(Clone)]
+struct BlobUpdateRows {
+ updated: Arc<[usize]>,
+ next_position: Arc<AtomicUsize>,
+}
+
// Remote FileRead ranges open new requests. Keep read-ahead independent of
// the Java-sized copy buffer so a 4 KiB buffer does not mean 4 KiB GETs.
const SOURCE_READ_SIZE: u64 = 8 * 1024 * 1024;
@@ -56,6 +65,7 @@ pub(crate) struct BlobWriterFactory {
consumer: Option<Arc<dyn BlobConsumer>>,
uri_reader_factory: Option<Arc<dyn UriReaderFactory>>,
copy_buffer_size: usize,
+ update_rows: Option<BlobUpdateRows>,
}
impl BlobWriterFactory {
@@ -72,6 +82,7 @@ impl BlobWriterFactory {
uri_reader_factory: None,
copy_buffer_size: CoreOptions::new(options.unwrap_or(&defaults))
.blob_copy_buffer_size()?,
+ update_rows: None,
})
}
@@ -84,6 +95,16 @@ impl BlobWriterFactory {
self.uri_reader_factory = Some(factory);
self
}
+
+ /// Emit placeholders for unchanged rows of a column with a physical
baseline.
+ pub(crate) fn with_update_rows(mut self, updated: Vec<usize>) -> Self {
+ debug_assert!(updated.windows(2).all(|pair| pair[0] < pair[1]));
+ self.update_rows = Some(BlobUpdateRows {
+ updated: updated.into(),
+ next_position: Arc::new(AtomicUsize::new(0)),
+ });
+ self
+ }
}
#[async_trait]
@@ -107,7 +128,8 @@ impl FormatWriterFactory for BlobWriterFactory {
)?
.with_copy_buffer_size(self.copy_buffer_size)
.with_uri_reader_factory(self.uri_reader_factory.clone())
- .with_consumer(self.consumer.clone()),
+ .with_consumer(self.consumer.clone())
+ .with_update_rows(self.update_rows.clone()),
))
}
}
@@ -124,6 +146,7 @@ pub(crate) struct BlobFormatWriter {
copy_buffer_size: usize,
bytes_written: u64,
lengths: Vec<i64>,
+ update_rows: Option<BlobUpdateRows>,
}
fn invalid(message: impl Into<String>) -> Error {
@@ -167,6 +190,7 @@ impl BlobFormatWriter {
copy_buffer_size: 4 * 1024,
bytes_written: 0,
lengths: Vec::new(),
+ update_rows: None,
})
}
@@ -189,6 +213,11 @@ impl BlobFormatWriter {
self
}
+ fn with_update_rows(mut self, rows: Option<BlobUpdateRows>) -> Self {
+ self.update_rows = rows;
+ self
+ }
+
fn accept(&self, offset: u64, length: i64) -> Result<bool> {
match &self.consumer {
Some(consumer) => consumer.accept(
@@ -466,7 +495,7 @@ impl BlobFormatWriter {
async fn write_collection(
&mut self,
values: &LargeBinaryArray,
- keys: Option<Vec<Vec<u8>>>,
+ keys: Option<Vec<Option<Vec<u8>>>>,
) -> Result<()> {
let (start, mut hasher) = self.start_record().await?;
let (magic, version) = if keys.is_some() {
@@ -484,6 +513,10 @@ impl BlobFormatWriter {
let key_lengths = if let Some(keys) = keys {
let mut lengths = Vec::with_capacity(keys.len());
for key in keys {
+ let Some(key) = key else {
+ lengths.push(-1);
+ continue;
+ };
lengths.push(
i64::try_from(key.len()).map_err(|_| invalid("BLOB MAP key
exceeds i64"))?,
);
@@ -525,7 +558,22 @@ impl FormatFileWriter for BlobFormatWriter {
}
let column = batch.column(0);
let kind = self.kind.clone();
+ let first_position = self
+ .update_rows
+ .as_ref()
+ .map_or(0, |rows| rows.next_position.load(Ordering::Relaxed));
+ let end_position = first_position
+ .checked_add(batch.num_rows())
+ .ok_or_else(|| invalid("BLOB update row position overflows
usize"))?;
for row in 0..batch.num_rows() {
+ if self
+ .update_rows
+ .as_ref()
+ .is_some_and(|rows|
rows.updated.binary_search(&(first_position + row)).is_err())
+ {
+ self.lengths.push(-2);
+ continue;
+ }
if column.is_null(row) {
self.lengths.push(-1);
if let Some(consumer) = &self.consumer {
@@ -548,11 +596,7 @@ impl FormatFileWriter for BlobFormatWriter {
.await?;
}
BlobFieldKind::Map(key_type) => {
- let map = column
- .as_any()
- .downcast_ref::<MapArray>()
- .ok_or_else(|| invalid("MAP<X, BLOB> writer requires
MapArray"))?;
- let entries = map.value(row);
+ let entries = map_entries(column.as_ref(), row)?;
// Java validates all keys before starting the record.
let keys = serialize_map_keys(entries.column(0).as_ref(),
key_type)?;
self.write_collection(blob_values(entries.column(1).as_ref())?, Some(keys))
@@ -560,6 +604,9 @@ impl FormatFileWriter for BlobFormatWriter {
}
}
}
+ if let Some(rows) = &self.update_rows {
+ rows.next_position.store(end_position, Ordering::Relaxed);
+ }
Ok(())
}
@@ -627,12 +674,34 @@ fn validate_key_type(key_type: &DataType) -> Result<()> {
}
}
-fn serialize_map_keys(array: &dyn Array, key_type: &DataType) ->
Result<Vec<Vec<u8>>> {
+/// Arrow Map cannot represent NULL keys. Row updates may transport entries
+/// as a list of key/value structs; physical bytes still use Java's map layout.
+fn map_entries(array: &dyn Array, row: usize) -> Result<StructArray> {
+ if let Some(map) = array.as_any().downcast_ref::<MapArray>() {
+ return Ok(map.value(row));
+ }
+ if let Some(list) = array.as_any().downcast_ref::<ListArray>() {
+ if let Some(entries) =
list.value(row).as_any().downcast_ref::<StructArray>() {
+ if entries.num_columns() == 2 && entries.null_count() == 0 {
+ return Ok(entries.clone());
+ }
+ }
+ }
+ Err(invalid(
+ "MAP<X, BLOB> writer requires MapArray or a list of key/value structs",
+ ))
+}
+
+fn serialize_map_keys(array: &dyn Array, key_type: &DataType) ->
Result<Vec<Option<Vec<u8>>>> {
let mut seen = HashSet::new();
let mut keys = Vec::with_capacity(array.len());
for row in 0..array.len() {
if array.is_null(row) {
- return Err(invalid("Arrow MAP keys must not be null"));
+ if !seen.insert(None) {
+ return Err(invalid("MAP<X, BLOB> keys must be unique"));
+ }
+ keys.push(None);
+ continue;
}
macro_rules! fixed {
($array:ty) => {
@@ -704,10 +773,10 @@ fn serialize_map_keys(array: &dyn Array, key_type:
&DataType) -> Result<Vec<Vec<
})
}
};
- if !seen.insert(key.clone()) {
+ if !seen.insert(Some(key.clone())) {
return Err(invalid("MAP<X, BLOB> keys must be unique"));
}
- keys.push(key);
+ keys.push(Some(key));
}
Ok(keys)
}
@@ -843,7 +912,11 @@ mod tests {
),
];
for (data_type, array) in types {
- let keys = serialize_map_keys(array.as_ref(), &data_type).unwrap();
+ let keys = serialize_map_keys(array.as_ref(), &data_type)
+ .unwrap()
+ .into_iter()
+ .map(Option::unwrap)
+ .collect::<Vec<_>>();
let bytes: Vec<_> =
keys.iter().cloned().map(Bytes::from).collect();
let restored = super::super::decode_blob_map_keys(&bytes,
&data_type).unwrap();
assert_eq!(restored.to_data(), array.to_data(), "{data_type:?}");
@@ -876,4 +949,19 @@ mod tests {
}
assert!(validate_key_type(&DataType::Float(FloatType::new())).is_err());
}
+
+ #[test]
+ fn nullable_map_keys_distinguish_java_null_and_empty_encodings() {
+ let kind = DataType::VarChar(VarCharType::new(10).unwrap());
+ let keys = StringArray::from(vec![None, Some(""), Some("normal")]);
+ assert_eq!(
+ serialize_map_keys(&keys, &kind).unwrap(),
+ vec![None, Some(vec![]), Some(b"normal".to_vec())]
+ );
+ let duplicate = StringArray::from(vec![None::<&str>, None]);
+ assert!(serialize_map_keys(&duplicate, &kind)
+ .unwrap_err()
+ .to_string()
+ .contains("unique"));
+ }
}
diff --git a/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
index 010bc5c6..86a46773 100644
--- a/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
+++ b/crates/paimon/src/arrow/format/blob/writer_consumer_tests.rs
@@ -57,6 +57,7 @@ impl FileWrite for RecordingOutput {
fn writer(kind: BlobFieldKind, output: &Arc<Mutex<OutputState>>) ->
BlobFormatWriter {
BlobFormatWriter {
+ update_rows: None,
writer: Box::new(RecordingOutput(output.clone())),
file_io: None,
kind,
@@ -148,14 +149,67 @@ async fn
scalar_consumer_runs_after_record_and_null_does_not_flush() {
assert_eq!(writer.lengths, [19, 16, -1]);
}
+#[tokio::test]
+async fn sparse_delta_uses_java_tags_and_only_exposes_updated_values() {
+ let output = Arc::new(Mutex::new(OutputState::default()));
+ let events = Arc::new(Mutex::new(Vec::new()));
+ let mut writer = writer(BlobFieldKind::Scalar, &output)
+ .with_consumer(Some(consumer(&events, &output)))
+ .with_update_rows(Some(BlobUpdateRows {
+ updated: vec![1, 3].into(),
+ next_position: Arc::new(AtomicUsize::new(0)),
+ }));
+ let batch = RecordBatch::try_from_iter([(
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(b"not copied".as_slice()),
+ None,
+ Some(b"also not copied"),
+ Some(b"new"),
+ ])) as ArrayRef,
+ )])
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ assert_eq!(writer.lengths, [-2, -1, -2, 19]);
+ check_payloads(
+ &output.lock().unwrap(),
+ &events.lock().unwrap(),
+ &[None, Some(b"new")],
+ );
+}
+
+#[tokio::test]
+async fn sparse_cursor_survives_physical_writer_recreation() {
+ let rows = BlobUpdateRows {
+ updated: vec![2].into(),
+ next_position: Arc::new(AtomicUsize::new(0)),
+ };
+ let output = Arc::new(Mutex::new(OutputState::default()));
+ let mut first = writer(BlobFieldKind::Scalar,
&output).with_update_rows(Some(rows.clone()));
+ let batch = RecordBatch::try_from_iter([(
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ None,
+ None,
+ Some(b"new".as_slice()),
+ ])) as ArrayRef,
+ )])
+ .unwrap();
+ first.write(&batch.slice(0, 2)).await.unwrap();
+ assert_eq!(first.lengths, [-2, -2]);
+ let mut second = writer(BlobFieldKind::Scalar,
&output).with_update_rows(Some(rows));
+ second.write(&batch.slice(2, 1)).await.unwrap();
+ assert_eq!(second.lengths, [19]);
+}
+
#[tokio::test]
async fn collection_consumer_skips_null_elements_and_flushes_after_trailer() {
for keys in [
None,
Some(vec![
- b"key-a".to_vec(),
- b"key-null".to_vec(),
- b"key-b".to_vec(),
+ Some(b"key-a".to_vec()),
+ Some(b"key-null".to_vec()),
+ Some(b"key-b".to_vec()),
]),
] {
let output = Arc::new(Mutex::new(OutputState::default()));
diff --git a/crates/paimon/src/table/data_evolution_blob.rs
b/crates/paimon/src/table/data_evolution_blob.rs
new file mode 100644
index 00000000..ea347aef
--- /dev/null
+++ b/crates/paimon/src/table/data_evolution_blob.rs
@@ -0,0 +1,253 @@
+// 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.
+
+//! Sparse Blob updates compatible with Java BlobFallbackRecordReader.
+
+use super::{matched_column, FileRowRange};
+use crate::spec::{CoreOptions, DataField, DataFileMeta};
+use crate::table::Table;
+use crate::Result;
+use arrow_array::RecordBatch;
+use arrow_select::interleave::interleave;
+use std::collections::HashSet;
+use std::sync::Arc;
+
+pub(super) struct BlobUpdateBatch {
+ pub field: DataField,
+ pub batch: RecordBatch,
+ pub updated_rows: Option<Vec<usize>>,
+}
+
+/// The committer assigns one snapshot sequence to the entire Blob delta.
+pub(super) fn assign_delta_metadata(mut first_row_id: i64, files: &mut
[DataFileMeta]) {
+ for file in files {
+ file.first_row_id = Some(first_row_id);
+ first_row_id += file.row_count;
+ file.min_sequence_number = 0;
+ file.max_sequence_number = 0;
+ }
+}
+
+pub(super) async fn file_updates(
+ table: &Table,
+ fields: &[DataField],
+ batches: &[RecordBatch],
+ matches: &[(usize, usize, usize)],
+ file: &FileRowRange,
+) -> Result<Vec<BlobUpdateBatch>> {
+ let inline =
CoreOptions::new(table.schema().options()).blob_inline_fields();
+ let fields = fields
+ .iter()
+ .filter(|field| is_dedicated_blob(field, &inline))
+ .collect::<Vec<_>>();
+ if fields.is_empty() {
+ return Ok(Vec::new());
+ }
+ let baseline = baseline_field_ids(table, &file.files).await?;
+ fields
+ .into_iter()
+ .map(|field| {
+ let (batch, updated_rows) = delta_batch(
+ field,
+ batches,
+ matches,
+ file.row_count as usize,
+ baseline.contains(&field.id()),
+ )?;
+ Ok(BlobUpdateBatch {
+ field: field.clone(),
+ batch,
+ updated_rows,
+ })
+ })
+ .collect()
+}
+
+pub(super) fn is_dedicated_blob(field: &DataField, inline: &HashSet<String>)
-> bool {
+ field.data_type().is_blob_file_field() && !inline.contains(field.name())
+}
+
+/// The ordinary-column rewrite never requests dedicated Blob payloads. Keep
+/// their independently rolled ranges out of its physical merge plan as well.
+pub(super) fn normal_read_files(files: &[DataFileMeta]) -> Vec<DataFileMeta> {
+ files
+ .iter()
+ .filter(|file| {
+
!crate::table::dedicated_format_file_writer::is_blob_or_video_file_name(&file.file_name)
+ })
+ .cloned()
+ .collect()
+}
+
+/// Resolve providers by field ID, so a dropped column's files cannot supply
+/// a subsequently added Blob column with the same name.
+pub(super) async fn baseline_field_ids(
+ table: &Table,
+ files: &[DataFileMeta],
+) -> Result<HashSet<i32>> {
+ let mut ids = HashSet::new();
+ for file in files {
+ if !file.file_name.to_ascii_lowercase().ends_with(".blob") {
+ continue;
+ }
+ let columns = file
+ .write_cols
+ .as_ref()
+ .filter(|columns| columns.len() == 1)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' must identify exactly one write column",
+ file.file_name
+ ),
+ source: None,
+ })?;
+ let stored = if file.schema_id == table.schema().id() {
+ None
+ } else {
+ Some(table.schema_manager().schema(file.schema_id).await?)
+ };
+ let fields = stored
+ .as_ref()
+ .map_or(table.schema().fields(), |schema| schema.fields());
+ ids.extend(
+ super::super::data_evolution_fields::project_by_paths(fields,
columns)?
+ .iter()
+ .map(DataField::id),
+ );
+ }
+ Ok(ids)
+}
+
+/// Only updated payloads enter the Arrow array. Unchanged values are NULL in
+/// memory and receive a distinct placeholder tag when a baseline exists.
+pub(super) fn delta_batch(
+ field: &DataField,
+ batches: &[RecordBatch],
+ matches: &[(usize, usize, usize)],
+ row_count: usize,
+ has_baseline: bool,
+) -> Result<(RecordBatch, Option<Vec<usize>>)> {
+ let length = if has_baseline {
+ matches.last().map_or(0, |(offset, _, _)| offset + 1)
+ } else {
+ row_count
+ };
+ let target = update_arrow_type(field, batches)?;
+ let mut arrays = vec![arrow_array::new_null_array(&target, 1)];
+ for batch in batches {
+ arrays.push(super::super::update_input::cast_update_value(
+ &matched_column(batch, field.name())?,
+ &target,
+ super::super::update_input::CastMode::RowUpdate,
+ )?);
+ }
+ let mut indices = vec![(0, 0); length];
+ for &(offset, batch, row) in matches {
+ if offset >= row_count || offset >= length {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Blob update position {offset} exceeds logical row count
{row_count}"
+ ),
+ source: None,
+ });
+ }
+ if !field.data_type().is_nullable() && arrays[batch + 1].is_null(row) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot update non-nullable Blob '{}' to
NULL", field.name()),
+ source: None,
+ });
+ }
+ indices[offset] = (batch + 1, row);
+ }
+ if !has_baseline && !field.data_type().is_nullable() && matches.len() !=
row_count {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Non-nullable Blob '{}' has no baseline for unchanged rows",
+ field.name()
+ ),
+ source: None,
+ });
+ }
+ let values = interleave(
+ &arrays
+ .iter()
+ .map(|array| array.as_ref())
+ .collect::<Vec<_>>(),
+ &indices,
+ )
+ .map_err(|error| crate::Error::DataInvalid {
+ message: format!(
+ "Failed to assemble Blob update for '{}': {error}",
+ field.name()
+ ),
+ source: Some(Box::new(error)),
+ })?;
+ // Unchanged positions are NULL in Arrow and placeholders on disk, even
+ // for a non-nullable logical Blob column.
+ let schema =
crate::arrow::build_target_arrow_schema(std::slice::from_ref(field))?;
+ let schema = Arc::new(arrow_schema::Schema::new_with_metadata(
+ vec![schema
+ .field(0)
+ .clone()
+ .with_data_type(target)
+ .with_nullable(true)],
+ schema.metadata().clone(),
+ ));
+ let batch =
+ RecordBatch::try_new(schema, vec![values]).map_err(|error|
crate::Error::DataInvalid {
+ message: format!("Failed to build Blob update batch: {error}"),
+ source: Some(Box::new(error)),
+ })?;
+ Ok((
+ batch,
+ has_baseline.then(|| matches.iter().map(|(offset, _, _)|
*offset).collect()),
+ ))
+}
+
+/// Preserve Java's nullable map keys through a list of key/value structs.
+/// This representation is used only by the row-aware Blob update transport.
+fn update_arrow_type(field: &DataField, batches: &[RecordBatch]) ->
Result<arrow_schema::DataType> {
+ use arrow_schema::DataType;
+ let target = crate::arrow::paimon_type_to_arrow(field.data_type())?;
+ let DataType::Map(entries, _) = &target else {
+ return Ok(target);
+ };
+ let list_input = batches
+ .iter()
+ .map(|batch| matched_column(batch, field.name()))
+ .collect::<Result<Vec<_>>>()?
+ .iter()
+ .any(|array| {
+ matches!(array.data_type(), DataType::List(entry)
+ if matches!(entry.data_type(), DataType::Struct(fields) if
fields.len() == 2))
+ });
+ if !list_input {
+ return Ok(target);
+ }
+ let DataType::Struct(fields) = entries.data_type() else {
+ unreachable!("Arrow map entries are structs");
+ };
+ let fields = vec![
+ Arc::new(fields[0].as_ref().clone().with_nullable(true)),
+ fields[1].clone(),
+ ];
+ Ok(DataType::List(Arc::new(arrow_schema::Field::new(
+ "item",
+ DataType::Struct(fields.into()),
+ false,
+ ))))
+}
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 74c0c233..6c6a4bcd 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -36,10 +36,10 @@ use crate::table::commit_message::CommitMessage;
use crate::table::data_file_index_writer::FileIndexOptions;
use crate::table::data_file_writer::DataFileWriter;
use crate::table::index_file_path::IndexFileLocation;
-use crate::table::source::data_evolution_anchor_file;
+use crate::table::source::{data_evolution_anchor_file,
is_data_evolution_normal_file};
use crate::table::stats_filter::group_by_overlapping_row_id;
use crate::table::Table;
-use crate::table::{DataSplit, DataSplitBuilder};
+use crate::table::{DataSplit, DataSplitBuilder, RowRange};
use crate::Result;
use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch, StructArray};
use arrow_buffer::NullBuffer;
@@ -54,6 +54,9 @@ use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use uuid::Uuid;
+#[path = "data_evolution_blob.rs"]
+mod blob_updates;
+
const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
const DELETION_VECTORS_INDEX_VERSION_V1: u8 = 1;
const MANIFEST_DIR: &str = "manifest";
@@ -81,6 +84,7 @@ pub struct DataEvolutionWriter {
matched_batches: Vec<RecordBatch>,
matched_batch_groups: Vec<usize>,
next_group_id: usize,
+ blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
}
impl DataEvolutionWriter {
@@ -148,9 +152,7 @@ impl DataEvolutionWriter {
});
}
let partition_keys = schema.partition_keys();
- // Java CoreOptions.updatableBlobFields() includes descriptor and view
- // fields, both of which store their references in normal data files.
- let updatable_blob_fields = core_options.blob_inline_fields();
+ let video_fields = core_options.video_frame_fields();
for col in &update_columns {
let top_level =
DataEvolutionPartialWriter::top_level_write_name(col, schema.fields());
if !allow_partition_columns && partition_keys.iter().any(|key| key
== top_level) {
@@ -158,17 +160,10 @@ impl DataEvolutionWriter {
message: format!("Cannot update partition column '{col}'
in MERGE INTO"),
});
}
- if let Some(field) = schema.fields().iter().find(|f| f.name() ==
top_level) {
- if field.data_type().is_blob_file_field()
- && !updatable_blob_fields.contains(top_level)
- {
- return Err(crate::Error::Unsupported {
- message: format!(
- "Cannot update raw-data BLOB column '{col}' in
MERGE INTO. \
- Only inline BLOB columns listed in
'blob-descriptor-field' or 'blob-view-field' can be updated"
- ),
- });
- }
+ if video_fields.contains(top_level) {
+ return Err(crate::Error::Unsupported {
+ message: format!("Updating packed VIDEO column '{col}' is
not supported"),
+ });
}
}
@@ -180,9 +175,17 @@ impl DataEvolutionWriter {
matched_batches: Vec::new(),
matched_batch_groups: Vec::new(),
next_group_id: 1,
+ blob_uri_reader_factory: None,
})
}
+ pub(super) fn with_blob_uri_reader_factory(
+ &mut self,
+ factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+ ) {
+ self.blob_uri_reader_factory = factory;
+ }
+
fn validate_partition_values(
&self,
files: &[FileRowRange],
@@ -351,6 +354,14 @@ impl DataEvolutionWriter {
// 3. For each affected file: read original columns, apply updates,
write partial files
let mut writer = DataEvolutionPartialWriter::new(&self.table,
self.update_columns.clone())?;
+ writer.blob_uri_reader_factory = self.blob_uri_reader_factory.clone();
+ let inline =
CoreOptions::new(self.table.schema().options()).blob_inline_fields();
+ let normal_fields = self
+ .write_fields
+ .iter()
+ .filter(|field| !blob_updates::is_dedicated_blob(field, &inline))
+ .cloned()
+ .collect::<Vec<_>>();
let result = async {
for (&file_pos, matched_rows) in &file_matches {
@@ -358,22 +369,63 @@ impl DataEvolutionWriter {
let first_row_id = file_range.first_row_id;
let row_count = file_range.row_count as usize;
+ let mut sorted_matches: Vec<(usize, usize, usize)> =
matched_rows
+ .iter()
+ .map(|m| (m.offset, m.batch_idx, m.row_idx))
+ .collect();
+ sorted_matches.sort_by_key(|(offset, _, _)| *offset);
+ if sorted_matches.windows(2).any(|rows| rows[0].0 ==
rows[1].0) {
+ return Err(crate::Error::DataInvalid {
+ message: "Multiple updates target the same row
ID".to_string(),
+ source: None,
+ });
+ }
+ let blobs = blob_updates::file_updates(
+ &self.table,
+ &self.write_fields,
+ &self.matched_batches,
+ &sorted_matches,
+ file_range,
+ )
+ .await?;
+ if normal_fields.is_empty() {
+ for blob in blobs {
+ writer
+ .write_blob_batch(
+ file_range,
+ &blob.field,
+ blob.batch,
+ blob.updated_rows,
+ )
+ .await?;
+ }
+ continue;
+ }
+
// Read original columns from the entire file group (base +
partial-column files).
- let col_refs: Vec<&str> =
self.write_fields.iter().map(DataField::name).collect();
+ let mut col_refs: Vec<&str> =
normal_fields.iter().map(DataField::name).collect();
+ // Anchor row counts even when all ordinary SET columns were
+ // added after the original files were written.
+ col_refs.push("_ROW_ID");
let mut rb = read_table.new_read_builder();
rb.with_projection(&col_refs)?;
+ rb.with_row_ranges(vec![RowRange::new(
+ file_range.first_row_id,
+ file_range.last_row_id,
+ )]);
let read = rb.new_read()?;
// Base + partial-column files share row-id ranges, so physical
// row counts overcount the group's logical rows.
+ let read_files =
blob_updates::normal_read_files(&file_range.files);
let split = DataSplitBuilder::new()
.with_snapshot(file_range.snapshot_id)
.with_partition(BinaryRow::from_serialized_bytes(&file_range.partition)?)
.with_bucket(file_range.bucket)
.with_bucket_path(file_range.bucket_path.clone())
.with_total_buckets(file_range.total_buckets)
- .with_data_files(file_range.files.clone())
- .with_raw_convertible(file_range.files.len() == 1)
+ .with_raw_convertible(read_files.len() == 1)
+ .with_data_files(read_files)
.build()?;
let stream = read.to_arrow(&[split])?;
@@ -405,19 +457,7 @@ impl DataEvolutionWriter {
// Keep one physical ROW column per top-level field, even when
the
// update names identify several nested leaves of that ROW.
- let mut sorted_matches: Vec<(usize, usize, usize)> =
matched_rows
- .iter()
- .map(|m| (m.offset, m.batch_idx, m.row_idx))
- .collect();
- sorted_matches.sort_by_key(|(offset, _, _)| *offset);
- if sorted_matches.windows(2).any(|rows| rows[0].0 ==
rows[1].0) {
- return Err(crate::Error::DataInvalid {
- message: "Multiple updates target the same row
ID".to_string(),
- source: None,
- });
- }
- let new_columns = self
- .write_fields
+ let new_columns = normal_fields
.iter()
.enumerate()
.map(|(index, field)| {
@@ -431,7 +471,7 @@ impl DataEvolutionWriter {
)
})
.collect::<Result<Vec<_>>>()?;
- let write_schema =
crate::arrow::build_target_arrow_schema(&self.write_fields)?;
+ let write_schema =
crate::arrow::build_target_arrow_schema(&normal_fields)?;
let updated_batch =
RecordBatch::try_new(write_schema,
new_columns).map_err(|e| {
crate::Error::DataInvalid {
@@ -449,6 +489,11 @@ impl DataEvolutionWriter {
updated_batch,
)
.await?;
+ for blob in blobs {
+ writer
+ .write_blob_batch(file_range, &blob.field, blob.batch,
blob.updated_rows)
+ .await?;
+ }
}
// 4. Collect commit messages (caller is responsible for
committing)
@@ -585,7 +630,7 @@ impl DataEvolutionDeleteWriter {
.cloned()
.collect::<Vec<_>>();
- for group in group_by_overlapping_row_id(files) {
+ for group in group_by_normal_row_id(files) {
let anchor = data_evolution_anchor_file(&group)?;
let first_row_id =
anchor
@@ -1232,18 +1277,23 @@ impl RowIdFileIndex {
.cloned()
.collect();
- let groups = group_by_overlapping_row_id(all_files);
+ let groups = group_by_normal_row_id(all_files);
for group in groups {
// Compute the overall row_id range for this group.
// The base file has the widest range; partial-column files
share it.
- let first_row_id = group.iter().filter_map(|f|
f.first_row_id).min().unwrap();
+ let normal = group
+ .iter()
+ .filter(|file| is_data_evolution_normal_file(file))
+ .collect::<Vec<_>>();
+ let first_row_id = normal.iter().filter_map(|f|
f.first_row_id).min().unwrap();
let last_row_id = group
.iter()
+ .filter(|file| is_data_evolution_normal_file(file))
.filter_map(|f| f.row_id_range().map(|(_, end)| end))
.max()
.unwrap();
// The actual row count is the max among the group (base
file's count).
- let row_count = group.iter().map(|f|
f.row_count).max().unwrap();
+ let row_count = normal.iter().map(|f|
f.row_count).max().unwrap();
file_index.push(FileRowRange {
first_row_id,
@@ -1284,6 +1334,38 @@ impl RowIdFileIndex {
}
}
+/// Dedicated column files can cross normal-file boundaries. They provide
+/// values, but must not merge anchors or introduce otherwise absent row IDs.
+fn group_by_normal_row_id(files: Vec<DataFileMeta>) -> Vec<Vec<DataFileMeta>> {
+ let (normal, dedicated): (Vec<_>, Vec<_>) =
+ files.into_iter().partition(is_data_evolution_normal_file);
+ group_by_overlapping_row_id(normal)
+ .into_iter()
+ .map(|mut group| {
+ let first = group
+ .iter()
+ .filter_map(|file| file.first_row_id)
+ .min()
+ .unwrap();
+ let last = group
+ .iter()
+ .filter_map(|file| file.row_id_range().map(|(_, last)| last))
+ .max()
+ .unwrap();
+ group.extend(
+ dedicated
+ .iter()
+ .filter(|file| {
+ file.row_id_range()
+ .is_some_and(|(from, to)| from <= last && to >=
first)
+ })
+ .cloned(),
+ );
+ group
+ })
+ .collect()
+}
+
struct FileRowRange {
first_row_id: i64,
last_row_id: i64,
@@ -1304,7 +1386,7 @@ struct MatchedRow {
}
// ---------------------------------------------------------------------------
-// DataEvolutionPartialWriter — writes partial-column parquet files for data
evolution
+// DataEvolutionPartialWriter — writes partial-column data evolution files
// ---------------------------------------------------------------------------
/// Key: (partition_bytes, bucket, first_row_id)
@@ -1324,6 +1406,8 @@ type PartialCommitGroup = (Option<i64>,
Vec<DataFileMeta>);
/// `file_source = APPEND (0)`, caller-supplied `first_row_id`, and
`write_cols`.
/// VECTOR columns use dedicated `*.vector.<format>` files when
`vector.file.format`
/// is configured.
+/// Raw BLOB, ARRAY<BLOB> and MAP<X, BLOB> columns use dedicated `*.blob`
+/// files with Java's placeholder tags for unchanged rows.
pub(crate) struct DataEvolutionPartialWriter {
file_io: FileIO,
table_location: String,
@@ -1338,12 +1422,14 @@ pub(crate) struct DataEvolutionPartialWriter {
/// Writers keyed by (partition_bytes, bucket, first_row_id, file kind).
writers: HashMap<WriterKey, DataFileWriter>,
check_from_snapshots: HashMap<WriterBaseKey, i64>,
+ blob_uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum PartialFileKind {
Normal,
Vector,
+ Blob(i32),
}
#[derive(Clone)]
@@ -1354,7 +1440,6 @@ struct PartialWriteSet {
write_fields: Vec<DataField>,
write_columns: Vec<String>,
omit_write_cols: bool,
- column_indices: Vec<usize>,
schema: Arc<arrow_schema::Schema>,
file_index_options: Option<Arc<FileIndexOptions>>,
}
@@ -1383,10 +1468,16 @@ impl DataEvolutionPartialWriter {
.as_ref()
.and_then(|options|
options.project_to_fields(&write_set.write_fields))
.map(Arc::new);
- if write_set.kind == PartialFileKind::Vector &&
write_set.file_index_options.is_some() {
+ if write_set.kind != PartialFileKind::Normal &&
write_set.file_index_options.is_some() {
+ let kind = if write_set.kind == PartialFileKind::Vector {
+ "VECTOR"
+ } else {
+ "BLOB"
+ };
return Err(crate::Error::Unsupported {
- message: "FileIndex generation does not support dedicated
VECTOR partial files"
- .to_string(),
+ message: format!(
+ "FileIndex generation does not support dedicated
{kind} partial files"
+ ),
});
}
}
@@ -1410,6 +1501,7 @@ impl DataEvolutionPartialWriter {
write_sets,
writers: HashMap::new(),
check_from_snapshots: HashMap::new(),
+ blob_uri_reader_factory: None,
})
}
@@ -1437,17 +1529,19 @@ impl DataEvolutionPartialWriter {
)?;
let projected = super::data_evolution_fields::project_by_paths(fields,
write_columns)?;
let mut normal_fields = Vec::new();
- let mut normal_indices = Vec::new();
let mut vector_fields = Vec::new();
- let mut vector_indices = Vec::new();
-
- for (idx, field) in projected.into_iter().enumerate() {
- if vector_file_format.is_some() && matches!(field.data_type(),
DataType::Vector(_)) {
+ let inline = core_options.blob_inline_fields();
+ let mut blob_fields = Vec::new();
+
+ for field in projected {
+ if blob_updates::is_dedicated_blob(&field, &inline) {
+ blob_fields.push(field);
+ } else if vector_file_format.is_some()
+ && matches!(field.data_type(), DataType::Vector(_))
+ {
vector_fields.push(field);
- vector_indices.push(idx);
} else {
normal_fields.push(field);
- normal_indices.push(idx);
}
}
@@ -1484,7 +1578,6 @@ impl DataEvolutionPartialWriter {
core_options,
),
write_columns: normal_columns,
- column_indices: normal_indices,
schema,
file_index_options: None,
});
@@ -1500,12 +1593,24 @@ impl DataEvolutionPartialWriter {
write_fields: vector_fields,
write_columns: vector_columns,
omit_write_cols: false,
- column_indices: vector_indices,
schema,
file_index_options: None,
});
}
+ for field in blob_fields {
+ let schema =
crate::arrow::build_target_arrow_schema(std::slice::from_ref(&field))?;
+ write_sets.push(PartialWriteSet {
+ kind: PartialFileKind::Blob(field.id()),
+ target_file_size: core_options.blob_target_file_size(),
+ file_format: "blob".to_string(),
+ write_columns: vec![field.name().to_string()],
+ write_fields: vec![field],
+ omit_write_cols: false,
+ schema,
+ file_index_options: None,
+ });
+ }
Ok(write_sets)
}
@@ -1547,6 +1652,9 @@ impl DataEvolutionPartialWriter {
};
for write_set in self.write_sets.clone() {
+ if matches!(write_set.kind, PartialFileKind::Blob(_)) {
+ continue;
+ }
let key = (
partition_bytes.clone(),
bucket,
@@ -1554,33 +1662,8 @@ impl DataEvolutionPartialWriter {
write_set.kind,
);
if !self.writers.contains_key(&key) {
- let writer = DataFileWriter::new(
- self.file_io.clone(),
- self.table_location.clone(),
- partition_path.clone(),
- bucket,
- self.schema_id,
- write_set.target_file_size,
- self.file_compression.clone(),
- self.file_compression_zstd_level,
- self.write_buffer_size,
- write_set.file_format.clone(),
- write_set.write_fields.clone(),
- self.format_options.clone(),
- Some(0), // file_source: APPEND
- Some(first_row_id),
- (!write_set.omit_write_cols).then(||
write_set.write_columns.clone()),
- )?
- .with_file_index(write_set.file_index_options.clone());
- let writer = if self
- .write_sets
- .iter()
- .any(|set| set.kind == PartialFileKind::Vector)
- {
- writer.without_row_sidecar()
- } else {
- writer
- };
+ let writer =
+ self.new_file_writer(&write_set, partition_path.clone(),
bucket, first_row_id)?;
self.writers.insert(key.clone(), writer);
}
@@ -1592,12 +1675,104 @@ impl DataEvolutionPartialWriter {
Ok(())
}
+ async fn write_blob_batch(
+ &mut self,
+ file: &FileRowRange,
+ field: &DataField,
+ batch: RecordBatch,
+ updated_rows: Option<Vec<usize>>,
+ ) -> Result<()> {
+ let write_set = self
+ .write_sets
+ .iter()
+ .find(|set| set.kind == PartialFileKind::Blob(field.id()))
+ .expect("dedicated Blob field has a physical write set")
+ .clone();
+ let base_key = (file.partition.clone(), file.bucket,
file.first_row_id);
+ self.check_from_snapshots
+ .entry(base_key)
+ .or_insert(file.snapshot_id);
+ let key = (
+ file.partition.clone(),
+ file.bucket,
+ file.first_row_id,
+ write_set.kind,
+ );
+ if !self.writers.contains_key(&key) {
+ let partition_path = if self.partition_keys.is_empty() {
+ String::new()
+ } else {
+ self.partition_computer
+
.generate_partition_path(&BinaryRow::from_serialized_bytes(&file.partition)?)?
+ };
+ let mut writer =
+ self.new_file_writer(&write_set, partition_path, file.bucket,
file.first_row_id)?;
+ if let Some(rows) = updated_rows {
+ writer.set_blob_update_rows(rows,
self.blob_uri_reader_factory.clone())?;
+ } else if self.blob_uri_reader_factory.is_some() {
+ writer.set_blob_writer_options(None,
self.blob_uri_reader_factory.clone())?;
+ }
+ self.writers.insert(key.clone(), writer);
+ }
+ let writer = self.writers.get_mut(&key).unwrap();
+ // Dedicated Blob writers check payload bytes after each record.
+ for row in 0..batch.num_rows() {
+ writer.write(&batch.slice(row, 1)).await?;
+ }
+ Ok(())
+ }
+
+ fn new_file_writer(
+ &self,
+ write_set: &PartialWriteSet,
+ partition_path: String,
+ bucket: i32,
+ first_row_id: i64,
+ ) -> Result<DataFileWriter> {
+ let writer = DataFileWriter::new(
+ self.file_io.clone(),
+ self.table_location.clone(),
+ partition_path,
+ bucket,
+ self.schema_id,
+ write_set.target_file_size,
+ self.file_compression.clone(),
+ self.file_compression_zstd_level,
+ self.write_buffer_size,
+ write_set.file_format.clone(),
+ write_set.write_fields.clone(),
+ self.format_options.clone(),
+ Some(0),
+ Some(first_row_id),
+ (!write_set.omit_write_cols).then(||
write_set.write_columns.clone()),
+ )?
+ .with_file_index(write_set.file_index_options.clone());
+ Ok(
+ if self
+ .write_sets
+ .iter()
+ .any(|set| set.kind == PartialFileKind::Vector)
+ {
+ writer.without_row_sidecar()
+ } else {
+ writer
+ },
+ )
+ }
+
fn project_batch(batch: &RecordBatch, write_set: &PartialWriteSet) ->
Result<RecordBatch> {
let columns: Vec<ArrayRef> = write_set
- .column_indices
+ .write_fields
.iter()
- .map(|&idx| batch.column(idx).clone())
- .collect();
+ .map(|field| {
+ batch.column_by_name(field.name()).cloned().ok_or_else(|| {
+ crate::Error::DataInvalid {
+ message: format!("Missing data-evolution write column
'{}'", field.name()),
+ source: None,
+ }
+ })
+ })
+ .collect::<Result<Vec<_>>>()?;
RecordBatch::try_new(write_set.schema.clone(), columns).map_err(|e| {
crate::Error::DataInvalid {
message: format!(
@@ -1626,7 +1801,10 @@ impl DataEvolutionPartialWriter {
// Group files by (partition, bucket) since multiple first_row_ids may
share the same partition/bucket
let mut grouped: HashMap<(Vec<u8>, i32), PartialCommitGroup> =
HashMap::new();
- for ((partition_bytes, bucket, first_row_id, _kind), files) in results
{
+ for ((partition_bytes, bucket, first_row_id, kind), mut files) in
results {
+ if matches!(kind, PartialFileKind::Blob(_)) {
+ blob_updates::assign_delta_metadata(first_row_id, &mut files);
+ }
let base_key = (partition_bytes.clone(), bucket, first_row_id);
let check_from_snapshot =
check_from_snapshots.get(&base_key).copied();
let entry = grouped
@@ -2721,6 +2899,25 @@ mod tests {
assert_eq!(row_count, 100);
}
+ #[test]
+ fn dedicated_ranges_attach_to_normal_anchors_without_bridging_gaps() {
+ let groups = group_by_normal_row_id(vec![
+ make_test_file_meta("first.parquet", 2, Some(0), 1, None),
+ make_test_file_meta("second.parquet", 2, Some(4), 1, None),
+ make_test_file_meta("payload.blob", 8, Some(0), 1,
Some(vec!["payload".into()])),
+ make_test_file_meta("other.blob", 1, Some(10), 1,
Some(vec!["payload".into()])),
+ ]);
+ assert_eq!(groups.len(), 2);
+ for (group, expected) in groups.iter().zip([(0, 1), (4, 5)]) {
+ assert_eq!(group.len(), 2);
+ assert_eq!(
+ data_evolution_anchor_file(group).unwrap().row_id_range(),
+ Some(expected)
+ );
+ assert_eq!(group[1].file_name, "payload.blob");
+ }
+ }
+
#[tokio::test]
async fn test_rejects_non_data_evolution_table() {
let file_io = test_file_io();
diff --git a/crates/paimon/src/table/data_file_writer.rs
b/crates/paimon/src/table/data_file_writer.rs
index 14efe59d..099b9dea 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -170,11 +170,7 @@ impl DataFileWriter {
uri_reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
) -> Result<()> {
debug_assert!(self.current_writer.is_none());
- let mut factory = crate::arrow::format::blob::BlobWriterFactory::new(
- Some(self.file_io.clone()),
- self.write_fields.first(),
- Some(&self.format_options),
- )?;
+ let mut factory = self.blob_writer_factory()?;
// Java BlobFormatWriter relinquishes deletion rights whenever a
// consumer can expose a descriptor, even if a later callback fails.
self.delete_file_upon_abort = consumer.is_none();
@@ -188,6 +184,28 @@ impl DataFileWriter {
Ok(())
}
+ fn blob_writer_factory(&self) ->
Result<crate::arrow::format::blob::BlobWriterFactory> {
+ crate::arrow::format::blob::BlobWriterFactory::new(
+ Some(self.file_io.clone()),
+ self.write_fields.first(),
+ Some(&self.format_options),
+ )
+ }
+
+ pub(super) fn set_blob_update_rows(
+ &mut self,
+ updated: Vec<usize>,
+ reader_factory: Option<Arc<dyn crate::io::UriReaderFactory>>,
+ ) -> Result<()> {
+ debug_assert!(self.current_writer.is_none());
+ let mut factory =
self.blob_writer_factory()?.with_update_rows(updated);
+ if let Some(reader_factory) = reader_factory {
+ factory = factory.with_uri_reader_factory(reader_factory);
+ }
+ self.format_writer_factory = Some(Arc::new(factory));
+ Ok(())
+ }
+
/// Java's dedicated-format writer does not create auxiliary ROW files,
/// including for its normal columns.
pub(super) fn without_row_sidecar(mut self) -> Self {
diff --git a/crates/paimon/src/table/table_update_by_row_id.rs
b/crates/paimon/src/table/table_update_by_row_id.rs
index c320ca60..afec5f00 100644
--- a/crates/paimon/src/table/table_update_by_row_id.rs
+++ b/crates/paimon/src/table/table_update_by_row_id.rs
@@ -39,6 +39,7 @@ pub struct TableUpdateByRowId {
index: RowIdFileIndex,
updated: HashMap<i32, HashSet<i64>>,
messages: Vec<CommitMessage>,
+ blob_uri_reader_factory: Option<std::sync::Arc<dyn
crate::io::UriReaderFactory>>,
}
impl TableUpdateByRowId {
@@ -54,9 +55,20 @@ impl TableUpdateByRowId {
index,
updated: HashMap::new(),
messages: Vec::new(),
+ blob_uri_reader_factory: None,
})
}
+ /// URI reader selection for referenced Blob values. Core performs the
+ /// bounded or streamed copy; callers need not materialize payloads.
+ pub fn with_blob_uri_reader_factory(
+ &mut self,
+ factory: Option<std::sync::Arc<dyn crate::io::UriReaderFactory>>,
+ ) -> &mut Self {
+ self.blob_uri_reader_factory = factory;
+ self
+ }
+
/// Stage one logical Arrow table. Its chunks may share a file group.
/// Partition columns may carry their existing values. Changing them is
/// rejected before writing, because a move requires delete + insert.
@@ -74,6 +86,7 @@ impl TableUpdateByRowId {
.filter(|name| seen.insert(name.clone()))
.collect::<Vec<_>>();
let mut writer = DataEvolutionWriter::for_row_id(&self.table,
columns.clone())?;
+
writer.with_blob_uri_reader_factory(self.blob_uri_reader_factory.clone());
let batches = batches
.into_iter()
.map(super::update_input::normalize_row_ids)
diff --git a/crates/paimon/tests/blob_collection_write_test.rs
b/crates/paimon/tests/blob_collection_write_test.rs
index 0455d9a9..fa5df004 100644
--- a/crates/paimon/tests/blob_collection_write_test.rs
+++ b/crates/paimon/tests/blob_collection_write_test.rs
@@ -209,20 +209,6 @@ async fn
collections_use_java_blob_files_and_preserve_null_empty_and_sliced_valu
}
}
-#[tokio::test]
-async fn raw_blob_collection_updates_are_rejected_like_java() {
- let table = collection_table(&[]).await;
- for column in ["items", "attrs"] {
- assert!(
- table
- .new_write_builder()
- .new_data_evolution_writer(vec![column.into()])
- .is_err(),
- "raw Blob collection {column} must not be updated"
- );
- }
-}
-
#[test]
fn video_configuration_matches_java_and_does_not_fall_through_to_blob_writes()
{
fn schema(
diff --git a/crates/paimon/tests/blob_update_test.rs
b/crates/paimon/tests/blob_update_test.rs
new file mode 100644
index 00000000..a9f5346e
--- /dev/null
+++ b/crates/paimon/tests/blob_update_test.rs
@@ -0,0 +1,890 @@
+// 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.
+
+mod common;
+
+use arrow_array::builder::{LargeBinaryBuilder, ListBuilder, MapBuilder,
StringBuilder};
+use arrow_array::{
+ Array, ArrayRef, Int32Array, Int64Array, LargeBinaryArray, ListArray,
MapArray, RecordBatch,
+};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{
+ ArrayType, BlobDescriptor, BlobType, DataType, IntType, MapType, Schema,
TableSchema,
+ VarCharType,
+};
+use paimon::table::Table;
+use std::collections::HashSet;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+async fn table(options: &[(&str, &str)]) -> Table {
+ typed_table(DataType::Blob(BlobType::new()), options).await
+}
+
+async fn typed_table(payload_type: DataType, options: &[(&str, &str)]) ->
Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", payload_type)
+ .column("value", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("deletion-vectors.enabled", "true");
+ for (key, value) in options {
+ schema = schema.option(*key, *value);
+ }
+ let path = "memory:/blob_updates";
+ let (io, table) = memory_table(path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ table
+}
+
+fn matched(ids: Vec<i64>, values: Vec<Option<&[u8]>>) -> RecordBatch {
+ RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(ids)) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(values)) as ArrayRef,
+ ),
+ ])
+ .unwrap()
+}
+
+async fn apply(table: &Table, batch: RecordBatch) ->
Vec<paimon::table::CommitMessage> {
+ table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![batch])
+ .await
+ .unwrap()
+}
+
+async fn read_batch(table: &Table) -> RecordBatch {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ arrow_select::concat::concat_batches(&batches[0].schema(),
&batches).unwrap()
+}
+
+#[tokio::test]
+async fn
short_deltas_keep_prefix_placeholders_and_stop_before_unchanged_tail() {
+ let table = table(&[]).await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let messages = apply(&table, matched(vec![1], vec![Some(b"first")])).await;
+ assert_eq!(messages[0].new_files[0].row_count, 2);
+ builder.new_commit().commit(messages).await.unwrap();
+ let messages = apply(&table, matched(vec![2],
vec![Some(b"second")])).await;
+ assert_eq!(messages[0].new_files[0].row_count, 3);
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table)
+ .await
+ .iter()
+ .map(|row| row.1.clone())
+ .collect::<Vec<_>>(),
+ vec![
+ Some(b"old-0".to_vec()),
+ Some(b"first".to_vec()),
+ Some(b"second".to_vec()),
+ Some(b"old-3".to_vec()),
+ Some(b"old-4".to_vec()),
+ ]
+ );
+ let historic = table.copy_with_options(
+ [("scan.snapshot-id".into(), "1".into())]
+ .into_iter()
+ .collect(),
+ );
+ assert_eq!(rows(&historic).await[1].1, None);
+ let mut read = table.new_read_builder();
+ read.with_projection(&["payload"])
+ .unwrap()
+ .with_row_ranges(vec![paimon::table::RowRange::new(1, 2)]);
+ let plan = read.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let batch = arrow_select::concat::concat_batches(&batches[0].schema(),
&batches).unwrap();
+ let payloads = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert_eq!(
+ payloads.iter().collect::<Vec<_>>(),
+ vec![Some(b"first".as_slice()), Some(b"second".as_slice())]
+ );
+}
+
+#[tokio::test]
+async fn sparse_positions_continue_across_rolled_delta_files() {
+ let table = table(&[("blob.target-file-size", "20 B")]).await;
+ seed(&table).await;
+ let messages = apply(
+ &table,
+ matched(vec![0, 4], vec![Some(b"first"), Some(b"last")]),
+ )
+ .await;
+ let mut ranges = messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .map(|file| file.row_id_range().unwrap())
+ .collect::<Vec<_>>();
+ ranges.sort_unstable();
+ assert_eq!(ranges, vec![(0, 0), (1, 4)]);
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&table)
+ .await
+ .iter()
+ .map(|row| row.1.clone())
+ .collect::<Vec<_>>(),
+ vec![
+ Some(b"first".to_vec()),
+ None,
+ Some(vec![]),
+ Some(b"old-3".to_vec()),
+ Some(b"last".to_vec()),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn absent_blob_baseline_writes_real_nulls_for_the_entire_normal_range() {
+ let table = table(&[]).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .with_write_type(vec!["id".into(), "value".into()])
+ .unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 11, 12])) as ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let messages = apply(&table, matched(vec![1], vec![Some(b"new")])).await;
+ assert_eq!(
+ (
+ messages[0].new_files[0].first_row_id,
+ messages[0].new_files[0].row_count
+ ),
+ (Some(0), 3)
+ );
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![(0, None, 10), (1, Some(b"new".to_vec()), 11), (2, None, 12)]
+ );
+}
+
+#[tokio::test]
+async fn descriptor_updates_copy_only_matched_payloads() {
+ let table = table(&[]).await;
+ seed(&table).await;
+ let source = "memory:/external";
+ table
+ .file_io()
+ .new_output(source)
+ .unwrap()
+ .write(b"ignoredreplacementignored".to_vec().into())
+ .await
+ .unwrap();
+ let descriptor = BlobDescriptor::new(source.into(), 7, 11).serialize();
+ let messages = apply(&table, matched(vec![3],
vec![Some(&descriptor)])).await;
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ table.file_io().delete_file(source).await.unwrap();
+ assert_eq!(rows(&table).await[3].1, Some(b"replacement".to_vec()));
+}
+
+#[tokio::test]
+async fn non_nullable_blob_allows_placeholders_but_rejects_explicit_null() {
+ let table = typed_table(
+ DataType::Blob(BlobType::new())
+ .copy_with_nullable(false)
+ .unwrap(),
+ &[],
+ )
+ .await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ b"zero".as_slice(),
+ b"one",
+ b"two",
+ ])) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 11, 12])) as ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let messages = apply(&table, matched(vec![1], vec![Some(b"new")])).await;
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(rows(&table).await[0].1, Some(b"zero".to_vec()));
+ let error = builder
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![matched(vec![1], vec![None])])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("NULL"), "{error}");
+ assert_eq!(rows(&table).await[1].1, Some(b"new".to_vec()));
+}
+
+fn collection_values(map: bool, values: &[Option<Vec<Option<&[u8]>>>]) ->
ArrayRef {
+ if map {
+ let mut builder = MapBuilder::new(None, StringBuilder::new(),
LargeBinaryBuilder::new());
+ for row in values {
+ if let Some(row) = row {
+ for (index, value) in row.iter().enumerate() {
+ builder.keys().append_value(index.to_string());
+ builder.values().append_option(*value);
+ }
+ }
+ builder.append(row.is_some()).unwrap();
+ }
+ Arc::new(builder.finish())
+ } else {
+ let mut builder = ListBuilder::new(LargeBinaryBuilder::new());
+ for row in values {
+ if let Some(row) = row {
+ for value in row {
+ builder.values().append_option(*value);
+ }
+ }
+ builder.append(row.is_some());
+ }
+ Arc::new(builder.finish())
+ }
+}
+
+#[tokio::test]
+async fn collection_updates_preserve_parent_null_empty_and_child_null_values()
{
+ for map in [false, true] {
+ for rolling in [false, true] {
+ let blob = DataType::Blob(BlobType::new());
+ let kind = if map {
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::new(100).unwrap()),
+ blob,
+ ))
+ } else {
+ DataType::Array(ArrayType::new(blob))
+ };
+ let table = typed_table(
+ kind,
+ &[(
+ "blob.target-file-size",
+ if rolling { "20 B" } else { "1 MB" },
+ )],
+ )
+ .await;
+ let original = collection_values(
+ map,
+ &[
+ Some(vec![Some(b"zero"), None, Some(b"")]),
+ None,
+ Some(vec![]),
+ Some(vec![Some(b"three")]),
+ Some(vec![Some(b"four")]),
+ ],
+ );
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![0, 1, 2, 3, 4])) as
ArrayRef,
+ ),
+ ("payload", original.clone()),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 11, 12, 13,
14])) as ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ // Sliced input exercises non-zero list/map offsets as well as
+ // unordered row IDs spanning separate input batches.
+ let values = collection_values(
+ map,
+ &[
+ Some(vec![Some(b"unused")]),
+ Some(vec![Some(b"new"), None, Some(b"")]),
+ None,
+ ],
+ );
+ let batch = RecordBatch::try_from_iter([
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![-1, 4, 1])) as ArrayRef,
+ ),
+ ("payload", values),
+ ])
+ .unwrap()
+ .slice(1, 2);
+ let messages = apply(&table, batch).await;
+ assert!(messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .all(|file| file.file_name.ends_with(".blob")));
+ builder.new_commit().commit(messages).await.unwrap();
+ let batch = read_batch(&table).await;
+ let expected = collection_values(
+ map,
+ &[
+ Some(vec![Some(b"zero"), None, Some(b"")]),
+ None,
+ Some(vec![]),
+ Some(vec![Some(b"three")]),
+ Some(vec![Some(b"new"), None, Some(b"")]),
+ ],
+ );
+ let expected = arrow_cast::cast(&expected,
batch.column(1).data_type()).unwrap();
+ assert_eq!(batch.column(1).to_data(), expected.to_data());
+ let descriptors = table.copy_with_options(
+ [("blob-as-descriptor".into(), "true".into())]
+ .into_iter()
+ .collect(),
+ );
+ let batch = read_batch(&descriptors).await;
+ let payload = batch.column(1);
+ assert!(payload.is_null(1));
+ let (children, empty) = if map {
+ let map = payload.as_any().downcast_ref::<MapArray>().unwrap();
+ (map.value(4).column(1).clone(), map.value_length(2))
+ } else {
+ let list =
payload.as_any().downcast_ref::<ListArray>().unwrap();
+ (list.value(4), list.value_length(2))
+ };
+ assert_eq!(empty, 0);
+ let values = children
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert!(values.is_null(1));
+ assert_eq!(
+ BlobDescriptor::deserialize(values.value(0))
+ .unwrap()
+ .length(),
+ 3
+ );
+ assert_eq!(
+ BlobDescriptor::deserialize(values.value(2))
+ .unwrap()
+ .length(),
+ 0
+ );
+ }
+ }
+}
+
+async fn seed(table: &Table) {
+ seed_chunks(table, 5).await;
+}
+
+async fn seed_chunks(table: &Table, chunk_size: usize) {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![0, 1, 2, 3, 4])) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(b"old-0".as_slice()),
+ None,
+ Some(b"".as_slice()),
+ Some(b"old-3".as_slice()),
+ Some(b"old-4".as_slice()),
+ ])) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 11, 12, 13, 14])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ for offset in (0..batch.num_rows()).step_by(chunk_size) {
+ writer
+ .write_arrow_batch(&batch.slice(offset,
chunk_size.min(batch.num_rows() - offset)))
+ .await
+ .unwrap();
+ }
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn independently_rolled_blob_files_do_not_define_update_ranges() {
+ let table = table(&[
+ ("blob.target-file-size", "35 B"),
+ ("target-file-row-num", "100"),
+ ])
+ .await;
+ seed(&table).await;
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ let normal_ranges = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .filter(|file| file.file_name.ends_with(".parquet"))
+ .map(|file| file.row_id_range().unwrap())
+ .collect::<Vec<_>>();
+ assert_eq!(normal_ranges, vec![(0, 4)]);
+ let blob_ranges = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .filter(|file| file.file_name.ends_with(".blob"))
+ .map(|file| file.row_id_range().unwrap())
+ .collect::<Vec<_>>();
+ assert!(
+ blob_ranges
+ .iter()
+ .any(|&(first, last)| first < 4 && last == 4),
+ "{blob_ranges:?}"
+ );
+ let builder = table.new_write_builder();
+ let mut update = builder.new_update().unwrap();
+ update
+ .with_update_type(vec!["payload".into(), "value".into()])
+ .unwrap();
+ let batch = RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![4])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![Some(b"new-4".as_slice())]))
as ArrayRef,
+ ),
+ ("value", Arc::new(Int32Array::from(vec![99])) as ArrayRef),
+ ])
+ .unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![batch])
+ .await
+ .unwrap();
+ assert!(messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .all(|file| file.first_row_id == Some(0) && file.row_count == 5));
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ (0, Some(b"old-0".to_vec()), 10),
+ (1, None, 11),
+ (2, Some(vec![]), 12),
+ (3, Some(b"old-3".to_vec()), 13),
+ (4, Some(b"new-4".to_vec()), 99),
+ ]
+ );
+}
+
+async fn rows(table: &Table) -> Vec<(i32, Option<Vec<u8>>, i32)> {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let blobs = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ let values = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ rows.extend(
+ ids.values()
+ .iter()
+ .zip(blobs.iter())
+ .zip(values.values())
+ .map(|((id, blob), value)| (*id, blob.map(Vec::from), *value)),
+ );
+ }
+ rows.sort_by_key(|row| row.0);
+ rows
+}
+
+#[tokio::test]
+async fn raw_blob_update_distinguishes_null_from_java_placeholder() {
+ let table = table(&[]).await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let mut update = builder.new_update().unwrap();
+ update.with_update_type(vec!["payload".into()]).unwrap();
+ let batch = RecordBatch::try_from_iter([
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![2, 4])) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ None,
+ Some(b"new-4".as_slice()),
+ ])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![batch])
+ .await
+ .unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].check_from_snapshot, Some(1));
+ assert_eq!(messages[0].new_files.len(), 1);
+ let delta = &messages[0].new_files[0];
+ assert!(delta.file_name.ends_with(".blob"));
+ assert_eq!(delta.write_cols, Some(vec!["payload".into()]));
+ assert_eq!((delta.first_row_id, delta.row_count), (Some(0), 5));
+ assert_eq!(
+ (delta.min_sequence_number, delta.max_sequence_number),
+ (0, 0)
+ );
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ (0, Some(b"old-0".to_vec()), 10),
+ (1, None, 11),
+ (2, None, 12),
+ (3, Some(b"old-3".to_vec()), 13),
+ (4, Some(b"new-4".to_vec()), 14),
+ ]
+ );
+}
+
+async fn physical_files(table: &Table) -> HashSet<String> {
+ table
+ .file_io()
+ .list_status_recursive(table.location())
+ .await
+ .unwrap()
+ .into_iter()
+ .filter(|entry| entry.path.ends_with(".blob") ||
entry.path.ends_with(".parquet"))
+ .map(|entry| entry.path)
+ .collect()
+}
+
+#[tokio::test]
+async fn raw_blob_upsert_matches_keys_before_writing_payloads() {
+ let table = table(&[("blob.target-file-size", "20 B")]).await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let mut update = builder.new_update().unwrap();
+ update.with_update_type(vec!["payload".into()]).unwrap();
+ let source = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1, 5, 5])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(b"matched".as_slice()),
+ Some(b"shadowed"),
+ None,
+ ])) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![111, 500, 501])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let messages = update
+ .upsert_by_arrow_with_key(vec![source], vec!["id".into()])
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ (0, Some(b"old-0".to_vec()), 10),
+ (1, Some(b"matched".to_vec()), 11),
+ (2, Some(vec![]), 12),
+ (3, Some(b"old-3".to_vec()), 13),
+ (4, Some(b"old-4".to_vec()), 14),
+ (5, None, 501),
+ ]
+ );
+}
+
+#[tokio::test]
+async fn raw_blob_update_uses_baseline_metadata_without_opening_old_payloads()
{
+ let table = table(&[]).await;
+ seed(&table).await;
+ for path in physical_files(&table)
+ .await
+ .into_iter()
+ .filter(|path| path.ends_with(".blob"))
+ {
+ table.file_io().delete_file(&path).await.unwrap();
+ }
+ let messages = apply(&table, matched(vec![1], vec![Some(b"new")])).await;
+ assert_eq!(messages[0].new_files[0].row_count, 2);
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+ let mut builder = table.new_read_builder();
+ builder.with_projection(&["id", "value"]).unwrap();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 5);
+}
+
+#[tokio::test]
+async fn abort_and_commit_conflicts_preserve_prepared_blob_files() {
+ for published in [false, true] {
+ let table = table(&[]).await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let mut updater = builder
+ .new_update()
+ .unwrap()
+ .new_update_by_row_id()
+ .await
+ .unwrap();
+ let messages = updater
+ .update_columns(
+ vec![matched(vec![1], vec![Some(b"new")])],
+ vec!["payload".into()],
+ )
+ .await
+ .unwrap();
+ let files = physical_files(&table).await;
+ if published {
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ }
+ updater.abort().await.unwrap();
+ updater.abort().await.unwrap();
+ assert_eq!(physical_files(&table).await, files);
+ if !published {
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ assert_eq!(rows(&table).await[1].1, Some(b"new".to_vec()));
+ }
+ let table = table(&[]).await;
+ seed(&table).await;
+ let stale = apply(&table, matched(vec![1], vec![Some(b"stale")])).await;
+ let latest = apply(&table, matched(vec![1], vec![Some(b"latest")])).await;
+ let files = physical_files(&table).await;
+ let builder = table.new_write_builder();
+ builder.new_commit().commit(latest).await.unwrap();
+ let error = builder.new_commit().commit(stale).await.unwrap_err();
+ assert!(error.to_string().contains("conflict"), "{error}");
+ assert_eq!(physical_files(&table).await, files);
+ assert_eq!(rows(&table).await[1].1, Some(b"latest".to_vec()));
+}
+
+#[tokio::test]
+async fn blob_update_keeps_deleted_rows_deleted_after_partial_rewrite() {
+ let table = table(&[]).await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let deletion = builder
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![0, 2])
+ .await
+ .unwrap();
+ builder.new_commit().commit(deletion).await.unwrap();
+ let messages = apply(&table, matched(vec![1], vec![Some(b"new")])).await;
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ rows(&table).await,
+ vec![
+ (1, Some(b"new".to_vec()), 11),
+ (3, Some(b"old-3".to_vec()), 13),
+ (4, Some(b"old-4".to_vec()), 14),
+ ]
+ );
+}
+
+struct BlobSource {
+ opened: AtomicUsize,
+ closed: Arc<AtomicUsize>,
+}
+
+struct BlobSourceFactory(Arc<BlobSource>);
+
+impl paimon::io::UriReaderFactory for BlobSourceFactory {
+ fn create(&self, uri: &str) -> paimon::Result<Arc<dyn
paimon::io::UriReader>> {
+ assert_eq!(uri, "custom://update");
+ Ok(self.0.clone())
+ }
+}
+
+#[async_trait::async_trait]
+impl paimon::io::UriReader for BlobSource {
+ async fn new_input_stream(
+ &self,
+ _uri: &str,
+ ) -> paimon::Result<Box<dyn paimon::io::UriInputStream>> {
+ self.opened.fetch_add(1, Ordering::Relaxed);
+ Ok(Box::new(BlobSourceStream {
+ position: 0,
+ closed: self.closed.clone(),
+ }))
+ }
+}
+
+struct BlobSourceStream {
+ position: usize,
+ closed: Arc<AtomicUsize>,
+}
+
+#[async_trait::async_trait]
+impl paimon::io::UriInputStream for BlobSourceStream {
+ async fn read(&mut self, length: usize) -> paimon::Result<bytes::Bytes> {
+ assert!(length <= 4096);
+ let bytes = bytes::Bytes::from_static(b"streamed value");
+ let end = (self.position + length).min(bytes.len());
+ let result = bytes.slice(self.position..end);
+ self.position = end;
+ Ok(result)
+ }
+
+ async fn close(&mut self) -> paimon::Result<()> {
+ self.closed.fetch_add(1, Ordering::Relaxed);
+ Ok(())
+ }
+}
+
+#[tokio::test]
+async fn
row_id_blob_reader_factory_streams_unknown_lengths_and_closes_sources() {
+ let table = table(&[("blob.target-file-size", "20 B")]).await;
+ seed(&table).await;
+ let reader = Arc::new(BlobSource {
+ opened: AtomicUsize::new(0),
+ closed: Arc::new(AtomicUsize::new(0)),
+ });
+ let builder = table.new_write_builder();
+ let mut updater = builder
+ .new_update()
+ .unwrap()
+ .new_update_by_row_id()
+ .await
+ .unwrap();
+
updater.with_blob_uri_reader_factory(Some(Arc::new(BlobSourceFactory(reader.clone()))));
+ let descriptor = BlobDescriptor::new("custom://update".into(), 0,
-1).serialize();
+ let messages = updater
+ .update_columns(
+ vec![matched(
+ vec![1, 4],
+ vec![Some(&descriptor), Some(&descriptor)],
+ )],
+ vec!["payload".into()],
+ )
+ .await
+ .unwrap();
+ assert_eq!(reader.opened.load(Ordering::Relaxed), 2);
+ assert_eq!(reader.closed.load(Ordering::Relaxed), 2);
+ builder.new_commit().commit(messages).await.unwrap();
+ let rows = rows(&table).await;
+ assert_eq!(rows[1].1, Some(b"streamed value".to_vec()));
+ assert_eq!(rows[4].1, Some(b"streamed value".to_vec()));
+}