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 0514f9a1 [core] Align dedicated Blob writes and URI resolution with 
Java (#989)
0514f9a1 is described below

commit 0514f9a1cf0a28b143136f5813d42fb45953524b
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 29 14:02:35 2026 +0800

    [core] Align dedicated Blob writes and URI resolution with Java (#989)
---
 Cargo.lock                                         |   5 +
 bindings/c/DEPENDENCIES.rust.tsv                   |   3 +
 bindings/go/DEPENDENCIES.rust.tsv                  |   3 +
 crates/integration_tests/DEPENDENCIES.rust.tsv     |   3 +
 crates/integrations/datafusion/tests/blob_tests.rs |  88 +++-
 crates/paimon-rest-server/DEPENDENCIES.rust.tsv    |   3 +
 crates/paimon/Cargo.toml                           |   2 +-
 crates/paimon/DEPENDENCIES.rust.tsv                |   3 +
 crates/paimon/src/arrow/format/blob.rs             |  13 +-
 crates/paimon/src/io/mod.rs                        |   1 +
 crates/paimon/src/io/uri_reader.rs                 | 406 +++++++++++++++
 crates/paimon/src/spec/core_options.rs             |   7 +
 crates/paimon/src/table/blob_resolver.rs           |  80 ++-
 crates/paimon/src/table/data_evolution_reader.rs   |   8 +-
 crates/paimon/src/table/data_evolution_writer.rs   |   8 +-
 crates/paimon/src/table/data_file_path_factory.rs  |  11 +
 crates/paimon/src/table/data_file_writer.rs        |  37 +-
 .../src/table/dedicated_format_file_writer.rs      | 129 +++--
 crates/paimon/src/table/inline_blob.rs             |  71 +++
 crates/paimon/src/table/managed_blob_reader.rs     |   9 +-
 crates/paimon/src/table/mod.rs                     |   1 +
 crates/paimon/src/table/table_write.rs             |  19 +-
 crates/paimon/tests/blob_http_error_test.rs        | 133 +++++
 crates/paimon/tests/dedicated_blob_write_test.rs   | 556 +++++++++++++++++++++
 24 files changed, 1452 insertions(+), 147 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 383487cc..e08d17a4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7228,12 +7228,17 @@ version = "0.6.11"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
 dependencies = [
+ "async-compression",
  "bitflags 2.13.1",
  "bytes",
+ "futures-core",
  "futures-util",
  "http",
  "http-body",
+ "http-body-util",
  "pin-project-lite",
+ "tokio",
+ "tokio-util",
  "tower",
  "tower-layer",
  "tower-service",
diff --git a/bindings/c/DEPENDENCIES.rust.tsv b/bindings/c/DEPENDENCIES.rust.tsv
index a60e13fa..149d0a46 100644
--- a/bindings/c/DEPENDENCIES.rust.tsv
+++ b/bindings/c/DEPENDENCIES.rust.tsv
@@ -24,6 +24,7 @@ [email protected]              X
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
[email protected]               X                                       
                                X                                       
 [email protected]                                                             
                        X                                       
 [email protected]                                                        
                                X                                       
 [email protected]             X                                               
                        X                                       
@@ -61,6 +62,8 @@ [email protected]          X                                       
                                X
 [email protected]             X                                                       
                X                                       
 [email protected]                                                                  
                X                                       
 [email protected]                                                              
                        X                                       
[email protected]              X                                       
                                X                                       
[email protected]                X                                       
                                X                                       
 [email protected]               X                                               
                        X                                       
 [email protected]                X                                               
                        X                                       
 [email protected]            X                                               
                        X                                       
diff --git a/bindings/go/DEPENDENCIES.rust.tsv 
b/bindings/go/DEPENDENCIES.rust.tsv
index a60e13fa..149d0a46 100644
--- a/bindings/go/DEPENDENCIES.rust.tsv
+++ b/bindings/go/DEPENDENCIES.rust.tsv
@@ -24,6 +24,7 @@ [email protected]              X
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
[email protected]               X                                       
                                X                                       
 [email protected]                                                             
                        X                                       
 [email protected]                                                        
                                X                                       
 [email protected]             X                                               
                        X                                       
@@ -61,6 +62,8 @@ [email protected]          X                                       
                                X
 [email protected]             X                                                       
                X                                       
 [email protected]                                                                  
                X                                       
 [email protected]                                                              
                        X                                       
[email protected]              X                                       
                                X                                       
[email protected]                X                                       
                                X                                       
 [email protected]               X                                               
                        X                                       
 [email protected]                X                                               
                        X                                       
 [email protected]            X                                               
                        X                                       
diff --git a/crates/integration_tests/DEPENDENCIES.rust.tsv 
b/crates/integration_tests/DEPENDENCIES.rust.tsv
index ede3938d..abe73663 100644
--- a/crates/integration_tests/DEPENDENCIES.rust.tsv
+++ b/crates/integration_tests/DEPENDENCIES.rust.tsv
@@ -23,6 +23,7 @@ [email protected]              X
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
[email protected]               X                                       
                                X                                       
 [email protected]                                                             
                        X                                       
 [email protected]                                                        
                                X                                       
 [email protected]             X                                               
                        X                                       
@@ -56,6 +57,8 @@ [email protected]          X                                       
                                X
 [email protected]             X                                                       
                X                                       
 [email protected]                                                                  
                X                                       
 [email protected]                                                              
                        X                                       
[email protected]              X                                       
                                X                                       
[email protected]                X                                       
                                X                                       
 [email protected]               X                                               
                        X                                       
 [email protected]            X                                               
                        X                                       
 [email protected]              X                                       
                                X                                       
diff --git a/crates/integrations/datafusion/tests/blob_tests.rs 
b/crates/integrations/datafusion/tests/blob_tests.rs
index 47364ead..8b2a9aed 100644
--- a/crates/integrations/datafusion/tests/blob_tests.rs
+++ b/crates/integrations/datafusion/tests/blob_tests.rs
@@ -35,6 +35,14 @@ fn to_hex(bytes: &[u8]) -> String {
     bytes.iter().map(|b| format!("{b:02X}")).collect()
 }
 
+// Inline descriptor columns contain references, not arbitrary payload bytes.
+fn descriptor_hex(directory: &std::path::Path, name: &str, payload: &[u8]) -> 
String {
+    let path = directory.join(name);
+    std::fs::write(&path, payload).unwrap();
+    let uri = path.to_str().unwrap().to_string();
+    to_hex(&BlobDescriptor::new(uri, 0, payload.len() as i64).serialize())
+}
+
 async fn setup(table_ddl: &str) -> (tempfile::TempDir, SQLContext) {
     let (tmp, catalog) = create_test_env();
     let sql_context = create_sql_context(catalog).await;
@@ -249,7 +257,7 @@ async fn test_blob_multiple_inserts() {
 /// blob-descriptor-field: listed fields are stored inline in parquet (no 
.blob files).
 #[tokio::test]
 async fn test_blob_descriptor_field_inline() {
-    let (_tmp, sql_context) = setup(
+    let (tmp, sql_context) = setup(
         "CREATE TABLE paimon.test_db.t (\
             id INT, \
             name STRING, \
@@ -262,9 +270,20 @@ async fn test_blob_descriptor_field_inline() {
     )
     .await;
 
+    let hello_hex = descriptor_hex(tmp.path(), "hello.bin", b"Hello");
+
+    assert_sql_error(
+        &sql_context,
+        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES (0, 
'Invalid', X'48656C6C6F')",
+        "blob-descriptor-field",
+    )
+    .await;
+
     exec(
         &sql_context,
-        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 'Alice', 
X'48656C6C6F')",
+        &format!(
+            "INSERT INTO paimon.test_db.t (id, name, picture) VALUES (1, 
'Alice', X'{hello_hex}')"
+        ),
     )
     .await;
 
@@ -357,7 +376,7 @@ async fn test_merge_into_rejects_raw_blob_update() {
 /// Reference: BlobTestBase "Blob: merge-into updates non-blob column on 
descriptor blob table"
 #[tokio::test]
 async fn test_merge_into_updates_non_blob_on_descriptor_table() {
-    let (_tmp, sql_context) = setup(
+    let (tmp, sql_context) = setup(
         "CREATE TABLE paimon.test_db.t (\
             id INT, \
             name STRING, \
@@ -370,11 +389,16 @@ async fn 
test_merge_into_updates_non_blob_on_descriptor_table() {
     )
     .await;
 
+    let first_hex = descriptor_hex(tmp.path(), "first.bin", b"AA");
+    let second_hex = descriptor_hex(tmp.path(), "second.bin", b"BB");
+
     exec(
         &sql_context,
-        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
-         (1, 'Alice', X'4141'), \
-         (2, 'Bob', X'4242')",
+        &format!(
+            "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+         (1, 'Alice', X'{first_hex}'), \
+         (2, 'Bob', X'{second_hex}')"
+        ),
     )
     .await;
 
@@ -406,7 +430,7 @@ async fn 
test_merge_into_updates_non_blob_on_descriptor_table() {
 /// Merge-into on a descriptor blob table: updating the blob column should 
succeed.
 #[tokio::test]
 async fn test_merge_into_updates_blob_on_descriptor_table() {
-    let (_tmp, sql_context) = setup(
+    let (tmp, sql_context) = setup(
         "CREATE TABLE paimon.test_db.t (\
             id INT, \
             name STRING, \
@@ -419,17 +443,23 @@ async fn 
test_merge_into_updates_blob_on_descriptor_table() {
     )
     .await;
 
+    let first_hex = descriptor_hex(tmp.path(), "first.bin", b"AA");
+    let second_hex = descriptor_hex(tmp.path(), "second.bin", b"BB");
+    let updated_hex = descriptor_hex(tmp.path(), "updated.bin", b"CC");
+
     exec(
         &sql_context,
-        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
-         (1, 'Alice', X'4141'), \
-         (2, 'Bob', X'4242')",
+        &format!(
+            "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+         (1, 'Alice', X'{first_hex}'), \
+         (2, 'Bob', X'{second_hex}')"
+        ),
     )
     .await;
 
     exec(
         &sql_context,
-        "CREATE TEMPORARY TABLE paimon.test_db.src AS SELECT * FROM (VALUES 
(1, X'4343')) AS t(id, picture)",
+        &format!("CREATE TEMPORARY TABLE paimon.test_db.src AS SELECT * FROM 
(VALUES (1, X'{updated_hex}')) AS t(id, picture)"),
     )
     .await;
 
@@ -673,7 +703,7 @@ async fn test_blob_rolling() {
 /// blob-descriptor-field with multiple inserts: descriptor values are 
resolved to actual data.
 #[tokio::test]
 async fn test_blob_descriptor_field_resolve_on_read() {
-    let (_tmp, sql_context) = setup(
+    let (tmp, sql_context) = setup(
         "CREATE TABLE paimon.test_db.t (\
             id INT, \
             name STRING, \
@@ -686,20 +716,28 @@ async fn test_blob_descriptor_field_resolve_on_read() {
     )
     .await;
 
+    let hello_hex = descriptor_hex(tmp.path(), "hello.bin", b"Hello");
+    let world_hex = descriptor_hex(tmp.path(), "world.bin", b"World");
+    let paimon_hex = descriptor_hex(tmp.path(), "paimon.bin", b"Paimon");
+
     exec(
         &sql_context,
-        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
-         (1, 'Alice', X'48656C6C6F'), \
+        &format!(
+            "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+         (1, 'Alice', X'{hello_hex}'), \
          (2, 'Bob', NULL), \
-         (3, 'Carol', X'576F726C64')",
+         (3, 'Carol', X'{world_hex}')"
+        ),
     )
     .await;
 
     // Second insert to exercise multi-file merge path.
     exec(
         &sql_context,
-        "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
-         (4, 'Dave', X'5061696D6F6E')",
+        &format!(
+            "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
+         (4, 'Dave', X'{paimon_hex}')"
+        ),
     )
     .await;
 
@@ -735,6 +773,8 @@ async fn 
test_blob_descriptor_field_resolve_descriptor_value() {
     )
     .await;
 
+    let hello_hex = descriptor_hex(tmp.path(), "hello.bin", b"Hello");
+
     // Write a source file that the BlobDescriptor will reference.
     let source_data = b"DescriptorResolved";
     let source_path = tmp.path().join("desc_source.bin");
@@ -747,7 +787,7 @@ async fn 
test_blob_descriptor_field_resolve_descriptor_value() {
     let sql = format!(
         "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
          (1, 'FromDesc', X'{desc_hex}'), \
-         (2, 'Raw', X'48656C6C6F')"
+         (2, 'FromSecondDesc', X'{hello_hex}')"
     );
     exec(&sql_context, &sql).await;
 
@@ -760,7 +800,7 @@ async fn 
test_blob_descriptor_field_resolve_descriptor_value() {
         rows,
         vec![
             (1, "FromDesc".into(), Some(b"DescriptorResolved".to_vec())),
-            (2, "Raw".into(), Some(b"Hello".to_vec())),
+            (2, "FromSecondDesc".into(), Some(b"Hello".to_vec())),
         ]
     );
 }
@@ -780,6 +820,8 @@ async fn 
test_blob_descriptor_field_resolve_unknown_length_descriptor() {
     )
     .await;
 
+    let known_hex = descriptor_hex(tmp.path(), "known.bin", b"RAW");
+
     let source_data = b"HEADER_PAYLOAD_TRAILER";
     let source_path = tmp.path().join("descriptor_unknown_length.bin");
     std::fs::write(&source_path, source_data).unwrap();
@@ -797,7 +839,7 @@ async fn 
test_blob_descriptor_field_resolve_unknown_length_descriptor() {
          (2, 'Suffix', X'{suffix_hex}'), \
          (3, 'Eof', X'{eof_hex}'), \
          (4, 'PastEof', X'{past_eof_hex}'), \
-         (5, 'Raw', X'524157'), \
+         (5, 'Known', X'{known_hex}'), \
          (6, 'Null', NULL)"
     );
     exec(&sql_context, &sql).await;
@@ -814,7 +856,7 @@ async fn 
test_blob_descriptor_field_resolve_unknown_length_descriptor() {
             (2, "Suffix".into(), Some(b"PAYLOAD_TRAILER".to_vec())),
             (3, "Eof".into(), Some(Vec::new())),
             (4, "PastEof".into(), Some(Vec::new())),
-            (5, "Raw".into(), Some(b"RAW".to_vec())),
+            (5, "Known".into(), Some(b"RAW".to_vec())),
             (6, "Null".into(), None),
         ]
     );
@@ -897,13 +939,15 @@ async fn 
test_blob_descriptor_filter_before_resolve_by_default() {
     )
     .await;
 
+    let kept_hex = descriptor_hex(tmp.path(), "kept.bin", b"OK");
+
     let missing_uri = format!("file://{}", 
tmp.path().join("missing_blob.bin").display());
     let bad_desc = BlobDescriptor::new(missing_uri, 0, 1);
     let bad_desc_hex = to_hex(&bad_desc.serialize());
     let sql = format!(
         "INSERT INTO paimon.test_db.t (id, name, picture) VALUES \
          (1, 'Filtered', X'{bad_desc_hex}'), \
-         (2, 'Kept', X'4F4B')"
+         (2, 'Kept', X'{kept_hex}')"
     );
     exec(&sql_context, &sql).await;
     let rows = query_id_name_picture(
diff --git a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv 
b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
index 3b4730a4..2e618470 100644
--- a/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
+++ b/crates/paimon-rest-server/DEPENDENCIES.rust.tsv
@@ -23,6 +23,7 @@ [email protected]              X
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
 [email protected]            X                                               
                                                                
[email protected]               X                                       
                                X                                       
 [email protected]                                                             
                        X                                       
 [email protected]                                                        
                                X                                       
 [email protected]             X                                               
                        X                                       
@@ -59,6 +60,8 @@ [email protected]          X                                       
                                X
 [email protected]             X                                                       
                X                                       
 [email protected]                                                                  
                X                                       
 [email protected]                                                              
                        X                                       
[email protected]              X                                       
                                X                                       
[email protected]                X                                       
                                X                                       
 [email protected]               X                                               
                        X                                       
 [email protected]            X                                               
                        X                                       
 [email protected]              X                                       
                                X                                       
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 7c971dcd..7fb7d914 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -128,7 +128,7 @@ tokio-util = { workspace = true, features = ["compat", 
"io-util"] }
 parquet = { workspace = true, features = ["async", "zstd", "lz4", "snap"] }
 orc-rust = "0.8.0"
 async-stream = "0.3.6"
-reqwest = { version = "0.12", features = ["json"] }
+reqwest = { version = "0.12", features = ["json", "gzip", "deflate"] }
 # DLF authentication dependencies
 base64 = "0.22"
 hex = "0.4"
diff --git a/crates/paimon/DEPENDENCIES.rust.tsv 
b/crates/paimon/DEPENDENCIES.rust.tsv
index af93908d..b05ba127 100644
--- a/crates/paimon/DEPENDENCIES.rust.tsv
+++ b/crates/paimon/DEPENDENCIES.rust.tsv
@@ -29,6 +29,7 @@ [email protected]           X
 [email protected]            X                                               
                                                                                
 [email protected]            X                                               
                                                                                
 [email protected]            X                                               
                        X                                                       
[email protected]               X                                       
                                X                                               
        
 [email protected]          X                                               
                        X                                                       
 [email protected]         X                                                       
                X                                                       
 [email protected]         X                                                       
                X                                                       
@@ -89,6 +90,8 @@ [email protected]          X                                       
                                X
 [email protected]             X                                                       
                X                                                       
 [email protected]                                                                  
                X                                                       
 [email protected]                                                              
                        X                                                       
[email protected]              X                                       
                                X                                               
        
[email protected]                X                                       
                                X                                               
        
 [email protected]         X                                               
                        X                                                       
 [email protected]               X                                               
                        X                                                       
 [email protected]                X                                               
                        X                                                       
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index 69d93efb..2280b9c1 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -2151,21 +2151,20 @@ impl FormatFileWriter for BlobFormatWriter {
                             .to_string(),
                     source: None,
                 })?;
-                let input = file_io.new_input(desc.uri())?;
+                let input = crate::io::uri_reader::UriInput::new(file_io, 
desc.uri())?;
                 let offset = range.offset();
                 let payload_len = match range.length() {
                     Some(length) => length,
                     None => input
-                        .metadata()
+                        .size()
                         .await
                         .map_err(|e| Error::UnexpectedError {
                             message: format!(
                                 "Failed to read metadata for BlobDescriptor 
'{}': {e}",
-                                desc.uri()
+                                
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
                             ),
                             source: Some(Box::new(e)),
                         })?
-                        .size
                         .saturating_sub(offset),
                 };
                 let end = offset
@@ -2191,7 +2190,7 @@ impl FormatFileWriter for BlobFormatWriter {
                 let reader = if payload_len == 0 {
                     None
                 } else {
-                    Some(input.reader().await?)
+                    Some(input.reader_for_range(offset..end).await?)
                 };
 
                 let mut hasher = crc32fast::Hasher::new();
@@ -2210,7 +2209,7 @@ impl FormatFileWriter for BlobFormatWriter {
                             Error::UnexpectedError {
                                 message: format!(
                                     "Failed to read BlobDescriptor '{}' range 
{pos}..{chunk_end}: {e}",
-                                    desc.uri()
+                                    
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
                                 ),
                                 source: Some(Box::new(e)),
                             }
@@ -2221,7 +2220,7 @@ impl FormatFileWriter for BlobFormatWriter {
                             return Err(Error::DataInvalid {
                                 message: format!(
                                     "Failed to read BlobDescriptor '{}': short 
read for range {pos}..{chunk_end}, expected={expected_len} bytes, 
actual={actual_len} bytes",
-                                    desc.uri()
+                                    
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
                                 ),
                                 source: None,
                             });
diff --git a/crates/paimon/src/io/mod.rs b/crates/paimon/src/io/mod.rs
index 5a075bcd..c7eea7c1 100644
--- a/crates/paimon/src/io/mod.rs
+++ b/crates/paimon/src/io/mod.rs
@@ -17,6 +17,7 @@
 
 pub(crate) mod cache;
 mod file_io;
+pub(crate) mod uri_reader;
 pub use file_io::*;
 
 mod storage;
diff --git a/crates/paimon/src/io/uri_reader.rs 
b/crates/paimon/src/io/uri_reader.rs
new file mode 100644
index 00000000..6324671d
--- /dev/null
+++ b/crates/paimon/src/io/uri_reader.rs
@@ -0,0 +1,406 @@
+// 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.
+
+//! URI dispatch for Blob references, matching Java's UriReaderFactory.
+//! Ordinary paths retain the table's FileIO, including provider credentials.
+
+use super::{FileIO, FileRead, InputFile};
+use crate::{Error, Result};
+use bytes::{Buf, Bytes, BytesMut};
+use reqwest::header::CONTENT_ENCODING;
+use std::ops::Range;
+use std::sync::{Arc, LazyLock};
+
+pub(crate) enum UriInput {
+    File(InputFile),
+    Http(HttpReader),
+}
+
+impl UriInput {
+    pub(crate) fn new(file_io: &FileIO, uri: &str) -> Result<Self> {
+        let http = uri.split_once(':').is_some_and(|(scheme, _)| {
+            scheme.eq_ignore_ascii_case("http") || 
scheme.eq_ignore_ascii_case("https")
+        });
+        if http {
+            Ok(Self::Http(HttpReader { uri: uri.into() }))
+        } else {
+            Ok(Self::File(file_io.new_input(uri)?))
+        }
+    }
+
+    pub(crate) async fn size(&self) -> Result<u64> {
+        match self {
+            Self::File(input) => Ok(input.metadata().await?.size),
+            Self::Http(reader) => reader.size().await,
+        }
+    }
+
+    pub(crate) async fn reader(&self) -> Result<Arc<dyn FileRead>> {
+        match self {
+            Self::File(input) => Ok(Arc::new(input.reader().await?)),
+            Self::Http(reader) => Ok(Arc::new(reader.clone())),
+        }
+    }
+
+    /// Reuse one HTTP response while copying a descriptor into a Blob file.
+    /// Reopening a Range-ignoring endpoint per chunk would reread every 
prefix.
+    pub(crate) async fn reader_for_range(&self, range: Range<u64>) -> 
Result<Arc<dyn FileRead>> {
+        match self {
+            Self::File(input) => Ok(Arc::new(input.reader().await?)),
+            Self::Http(reader) => Ok(Arc::new(reader.open(range).await?)),
+        }
+    }
+}
+
+static HTTP_CLIENT: LazyLock<reqwest::Client> = 
LazyLock::new(reqwest::Client::new);
+
+#[derive(Clone)]
+pub(crate) struct HttpReader {
+    uri: String,
+}
+
+impl HttpReader {
+    fn request(&self) -> reqwest::RequestBuilder {
+        HTTP_CLIENT.get(&self.uri)
+    }
+
+    async fn response(&self) -> Result<reqwest::Response> {
+        let response = self
+            .request()
+            .send()
+            .await
+            .map_err(http_error)?
+            .error_for_status()
+            .map_err(http_error)?;
+        if response.status() != reqwest::StatusCode::OK {
+            return Err(invalid(&format!(
+                "Unexpected HTTP Blob status: {}",
+                response.status()
+            )));
+        }
+        // Reqwest removes Content-Encoding after decoding gzip/deflate.
+        // Never silently copy an unsupported encoded representation as 
payload.
+        if response
+            .headers()
+            .get(CONTENT_ENCODING)
+            .is_some_and(|value| value.as_bytes() != b"identity")
+        {
+            return Err(invalid("Unsupported HTTP Blob Content-Encoding"));
+        }
+        Ok(response)
+    }
+
+    async fn size(&self) -> Result<u64> {
+        // GET also works with endpoints that reject HEAD. A chunked or decoded
+        // response has no known size: count without retaining the payload.
+        let mut response = self.response().await?;
+        if let Some(size) = response.content_length() {
+            return Ok(size);
+        }
+        let mut size = 0_u64;
+        while let Some(chunk) = response.chunk().await.map_err(http_error)? {
+            size = size
+                .checked_add(chunk.len() as u64)
+                .ok_or_else(|| invalid("HTTP Blob size exceeds u64"))?;
+        }
+        Ok(size)
+    }
+}
+
+impl HttpReader {
+    async fn open(&self, range: Range<u64>) -> Result<HttpRangeReader> {
+        if range.start >= range.end {
+            return Err(invalid("Invalid HTTP Blob range"));
+        }
+        // Like Java's HttpUriReader, offsets address the decoded entity.
+        // A wire Range may instead address compressed bytes, so use a decoded
+        // GET stream and skip the prefix once for the entire copy.
+        let response = self.response().await?;
+        let skip = range.start;
+        Ok(HttpRangeReader {
+            end: range.end,
+            state: tokio::sync::Mutex::new(HttpRangeState {
+                response,
+                position: range.start,
+                skip,
+                pending: Bytes::new(),
+            }),
+        })
+    }
+}
+
+#[async_trait::async_trait]
+impl FileRead for HttpReader {
+    async fn read(&self, range: Range<u64>) -> Result<Bytes> {
+        if range.start == range.end {
+            return Ok(Bytes::new());
+        }
+        self.open(range.clone()).await?.read(range).await
+    }
+}
+
+struct HttpRangeReader {
+    end: u64,
+    state: tokio::sync::Mutex<HttpRangeState>,
+}
+
+struct HttpRangeState {
+    response: reqwest::Response,
+    position: u64,
+    skip: u64,
+    pending: Bytes,
+}
+
+#[async_trait::async_trait]
+impl FileRead for HttpRangeReader {
+    async fn read(&self, range: Range<u64>) -> Result<Bytes> {
+        let mut state = self.state.lock().await;
+        if range.start != state.position || range.end < range.start || 
range.end > self.end {
+            return Err(invalid("HTTP Blob copy requires consecutive ranges"));
+        }
+        let length = range.end - range.start;
+        let mut remaining = length;
+        let mut result = BytesMut::new();
+        while remaining > 0 {
+            if state.pending.is_empty() {
+                let Some(chunk) = 
state.response.chunk().await.map_err(http_error)? else {
+                    return Err(invalid(&format!(
+                        "HTTP Blob short read: expected {length} bytes, 
received {}",
+                        length - remaining
+                    )));
+                };
+                state.pending = chunk;
+            }
+            let skip = state.skip.min(state.pending.len() as u64) as usize;
+            state.skip -= skip as u64;
+            state.pending.advance(skip);
+            let count = remaining.min(state.pending.len() as u64) as usize;
+            result.extend_from_slice(&state.pending[..count]);
+            state.pending.advance(count);
+            remaining -= count as u64;
+        }
+        state.position = range.end;
+        Ok(result.freeze())
+    }
+}
+
+fn http_error(error: reqwest::Error) -> Error {
+    // Both Display and Debug/source chains can expose the final URL after a
+    // redirect. Like Java HttpClientUtils, retain safe diagnostics only; even
+    // removing the request URL would not sanitize arbitrary nested causes.
+    let message = if let Some(status) = error.status() {
+        format!("HTTP Blob request failed with status {status}")
+    } else {
+        let operation = if error.is_timeout() {
+            "timeout"
+        } else if error.is_redirect() {
+            "redirect"
+        } else if error.is_connect() {
+            "connection"
+        } else if error.is_decode() {
+            "response decoding"
+        } else if error.is_builder() {
+            "request construction"
+        } else if error.is_body() {
+            "response body"
+        } else {
+            "request"
+        };
+        format!("HTTP Blob {operation} failed")
+    };
+    Error::UnexpectedError {
+        message,
+        source: None,
+    }
+}
+
+/// Safe URI context for Blob I/O errors, including signed URLs and userinfo.
+pub(crate) fn sanitize_blob_uri(uri: &str) -> String {
+    if let Ok(mut url) = url::Url::parse(uri) {
+        let _ = url.set_username("");
+        let _ = url.set_password(None);
+        url.set_query(None);
+        url.set_fragment(None);
+        return url.to_string();
+    }
+    if uri.split_once(':').is_some_and(|(scheme, _)| {
+        scheme.eq_ignore_ascii_case("http") || 
scheme.eq_ignore_ascii_case("https")
+    }) {
+        // A malformed authority cannot be safely split into host/userinfo.
+        return "<invalid HTTP URI>".into();
+    }
+    uri.split(['?', '#']).next().unwrap_or(uri).to_string()
+}
+
+fn invalid(message: &str) -> Error {
+    Error::DataInvalid {
+        message: message.into(),
+        source: None,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use axum::body::Body;
+    use axum::http::{Response, StatusCode};
+    use axum::response::Redirect;
+    use axum::routing::get;
+    use axum::Router;
+    use std::io::Write;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    #[test]
+    fn sanitize_http_uri_context() {
+        assert_eq!(
+            
sanitize_blob_uri("https://user:[email protected]/path?signature=secret#fragment";),
+            "https://example.com/path";
+        );
+        assert_eq!(
+            
sanitize_blob_uri("http://user:[email protected]:bad/path?signature=secret";),
+            "<invalid HTTP URI>"
+        );
+    }
+
+    #[tokio::test]
+    async fn http_reads_use_decoded_offsets_and_reuse_the_copy_stream() {
+        let requests = Arc::new(AtomicUsize::new(0));
+        let counter = requests.clone();
+        let app = Router::new()
+            .route(
+                "/copy",
+                get(move || {
+                    counter.fetch_add(1, Ordering::Relaxed);
+                    async { "0123456789" }
+                }),
+            )
+            .route("/ignored", get(|| async { "0123456789" }))
+            .route(
+                "/gzip",
+                get(|| async {
+                    let mut encoder =
+                        flate2::write::GzEncoder::new(Vec::new(), 
flate2::Compression::default());
+                    encoder.write_all(b"0123456789").unwrap();
+                    Response::builder()
+                        .header(CONTENT_ENCODING, "gzip")
+                        .body(Body::from(encoder.finish().unwrap()))
+                        .unwrap()
+                }),
+            )
+            .route(
+                "/deflate",
+                get(|| async {
+                    let mut encoder =
+                        flate2::write::ZlibEncoder::new(Vec::new(), 
flate2::Compression::default());
+                    encoder.write_all(b"0123456789").unwrap();
+                    Response::builder()
+                        .header(CONTENT_ENCODING, "deflate")
+                        .body(Body::from(encoder.finish().unwrap()))
+                        .unwrap()
+                }),
+            )
+            .route("/no-content", get(|| async { StatusCode::NO_CONTENT }))
+            .route(
+                "/redirect",
+                get(|| async { Redirect::temporary("/ignored") }),
+            )
+            .route(
+                "/chunked",
+                get(|| async {
+                    Body::from_stream(futures::stream::iter([
+                        Ok::<_, std::io::Error>(Bytes::from_static(b"012")),
+                        Ok(Bytes::from_static(b"34567")),
+                        Ok(Bytes::from_static(b"89")),
+                    ]))
+                }),
+            )
+            .route(
+                "/encoded",
+                get(|| async {
+                    Response::builder()
+                        .status(StatusCode::OK)
+                        .header(CONTENT_ENCODING, "unknown")
+                        .body(Body::from("0123"))
+                        .unwrap()
+                }),
+            );
+        let listener = 
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+        let address = listener.local_addr().unwrap();
+        let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
+        let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+        for route in ["ignored", "redirect", "chunked", "gzip", "deflate"] {
+            let input = UriInput::new(&io, 
&format!("http://{address}/{route}";)).unwrap();
+            let reader = input.reader().await.unwrap();
+            assert_eq!(
+                reader.read(3..7).await.unwrap().as_ref(),
+                b"3456",
+                "{route}"
+            );
+            assert!(reader.read(4..4).await.unwrap().is_empty());
+            assert_eq!(input.size().await.unwrap(), 10, "{route}");
+        }
+        let ignored = UriInput::new(&io, 
&format!("http://{address}/ignored";)).unwrap();
+        assert!(ignored
+            .reader()
+            .await
+            .unwrap()
+            .read(8..12)
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("short read"));
+        let copy = UriInput::new(&io, 
&format!("http://{address}/copy";)).unwrap();
+        let reader = copy.reader_for_range(1..9).await.unwrap();
+        assert_eq!(reader.read(1..4).await.unwrap().as_ref(), b"123");
+        assert_eq!(reader.read(4..9).await.unwrap().as_ref(), b"45678");
+        assert_eq!(requests.load(Ordering::Relaxed), 1);
+        let empty_status = UriInput::new(&io, 
&format!("http://{address}/no-content";)).unwrap();
+        assert!(empty_status
+            .size()
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("204"));
+        let encoded = UriInput::new(&io, 
&format!("http://{address}/encoded";)).unwrap();
+        assert!(encoded
+            .reader()
+            .await
+            .unwrap()
+            .read(0..2)
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("Content-Encoding"));
+        let missing = UriInput::new(&io, 
&format!("http://{address}/missing";)).unwrap();
+        assert!(missing
+            .size()
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("404"));
+        assert!(missing
+            .reader()
+            .await
+            .unwrap()
+            .read(0..1)
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("404"));
+        server.abort();
+    }
+}
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 9168d441..b4730a7c 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -760,6 +760,13 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(false)
     }
 
+    pub fn data_evolution_write_cols_optimization_enabled(&self) -> bool {
+        self.options
+            .get("data-evolution.write-cols-optimization.enabled")
+            .map(|value| value.eq_ignore_ascii_case("true"))
+            .unwrap_or(false)
+    }
+
     pub fn data_evolution_nested_field_enabled(&self) -> bool {
         self.options
             .get(DATA_EVOLUTION_NESTED_FIELD_ENABLED_OPTION)
diff --git a/crates/paimon/src/table/blob_resolver.rs 
b/crates/paimon/src/table/blob_resolver.rs
index cdf2fc7d..863c6553 100644
--- a/crates/paimon/src/table/blob_resolver.rs
+++ b/crates/paimon/src/table/blob_resolver.rs
@@ -16,6 +16,7 @@
 // under the License.
 
 use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
+use crate::io::uri_reader::sanitize_blob_uri;
 use crate::io::{FileIO, FileRead};
 use crate::spec::BlobDescriptor;
 use crate::Result;
@@ -303,17 +304,6 @@ fn blob_error_with_context(
     }
 }
 
-fn sanitize_blob_uri(uri: &str) -> String {
-    if let Ok(mut url) = url::Url::parse(uri) {
-        let _ = url.set_username("");
-        let _ = url.set_password(None);
-        url.set_query(None);
-        url.set_fragment(None);
-        return url.to_string();
-    }
-    uri.split(['?', '#']).next().unwrap_or(uri).to_string()
-}
-
 /// Shared admission control for external descriptor metadata and range reads.
 ///
 /// The byte semaphore budgets active range I/O only. A single range larger 
than
@@ -381,7 +371,8 @@ impl BlobReadLimiter {
             .await
             .map_err(|e| crate::Error::UnexpectedError {
                 message: format!(
-                    "Failed to acquire BlobDescriptor byte permits for URI 
'{uri}': {e}"
+                    "Failed to acquire BlobDescriptor byte permits for URI 
'{}': {e}",
+                    sanitize_blob_uri(uri)
                 ),
                 source: Some(Box::new(e)),
             })?;
@@ -395,7 +386,8 @@ impl BlobReadLimiter {
             .await
             .map_err(|e| crate::Error::UnexpectedError {
                 message: format!(
-                    "Failed to acquire BlobDescriptor {operation} permit for 
URI '{uri}': {e}"
+                    "Failed to acquire BlobDescriptor {operation} permit for 
URI '{}': {e}",
+                    sanitize_blob_uri(uri)
                 ),
                 source: Some(Box::new(e)),
             })
@@ -409,10 +401,30 @@ pub(crate) async fn resolve_blob_column(
     col: &LargeBinaryArray,
     file_io: &FileIO,
     limiter: BlobReadLimiter,
+) -> Result<LargeBinaryArray> {
+    resolve_column(col, file_io, limiter, false).await
+}
+
+/// Resolve a schema-declared descriptor column, including version 1 
descriptors
+/// which have no magic number and cannot be detected in arbitrary payload 
bytes.
+pub(crate) async fn resolve_descriptor_column(
+    col: &LargeBinaryArray,
+    file_io: &FileIO,
+    limiter: BlobReadLimiter,
+) -> Result<LargeBinaryArray> {
+    resolve_column(col, file_io, limiter, true).await
+}
+
+async fn resolve_column(
+    col: &LargeBinaryArray,
+    file_io: &FileIO,
+    limiter: BlobReadLimiter,
+    descriptor_only: bool,
 ) -> Result<LargeBinaryArray> {
     let mut needs_resolve = false;
     for i in 0..col.len() {
-        if !col.is_null(i) && BlobDescriptor::is_blob_descriptor(col.value(i)) 
{
+        if !col.is_null(i) && (descriptor_only || 
BlobDescriptor::is_blob_descriptor(col.value(i)))
+        {
             needs_resolve = true;
             break;
         }
@@ -433,7 +445,7 @@ pub(crate) async fn resolve_blob_column(
         }
 
         let value = col.value(row);
-        if BlobDescriptor::is_blob_descriptor(value) {
+        if descriptor_only || BlobDescriptor::is_blob_descriptor(value) {
             let desc = BlobDescriptor::deserialize(value)?;
             let range = desc.range_spec()?;
             requests_by_uri
@@ -453,17 +465,19 @@ pub(crate) async fn resolve_blob_column(
 
     let mut read_groups = Vec::with_capacity(requests_by_uri.len());
     for (uri, requests) in requests_by_uri {
-        let input = file_io.new_input(&uri)?;
+        let input = crate::io::uri_reader::UriInput::new(file_io, &uri)?;
         let file_size = if requests.iter().any(|request| 
request.length.is_none()) {
             let _metadata_permit = limiter.acquire_request(&uri, 
"metadata").await?;
             input
-                .metadata()
+                .size()
                 .await
                 .map_err(|e| crate::Error::UnexpectedError {
-                    message: format!("Failed to read metadata for 
BlobDescriptor URI '{uri}': {e}"),
+                    message: format!(
+                        "Failed to read metadata for BlobDescriptor URI '{}': 
{e}",
+                        sanitize_blob_uri(&uri)
+                    ),
                     source: Some(Box::new(e)),
                 })?
-                .size
         } else {
             0
         };
@@ -498,7 +512,7 @@ pub(crate) async fn resolve_blob_column(
             continue;
         }
 
-        let reader: Arc<dyn FileRead> = Arc::new(input.reader().await?);
+        let reader = input.reader().await?;
         read_groups.push(BlobReadGroup {
             uri,
             reader,
@@ -587,7 +601,7 @@ async fn read_merged_blob_ranges(
     let blob_parallelism = limiter.parallelism();
     stream::iter(reads)
         .map(|merged| {
-            let uri = uri.to_string();
+            let uri = sanitize_blob_uri(uri);
             let reader = reader.clone();
             let limiter = limiter.clone();
             async move {
@@ -715,6 +729,30 @@ fn merge_blob_read_requests(mut requests: 
Vec<BlobReadRequest>) -> Vec<MergedBlo
 mod tests {
     use super::*;
 
+    #[tokio::test]
+    async fn legacy_descriptor_parsing_requires_schema_context() {
+        let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+        let uri = "memory:/payload";
+        io.new_output(uri)
+            .unwrap()
+            .write(Bytes::from_static(b"hello"))
+            .await
+            .unwrap();
+        let v2 = BlobDescriptor::new(uri.into(), 1, 3).serialize();
+        let mut v1 = vec![1];
+        v1.extend_from_slice(&v2[9..]);
+        let column = LargeBinaryArray::from(vec![Some(v1.as_slice()), None]);
+        let raw = resolve_blob_column(&column, &io, BlobReadLimiter::new())
+            .await
+            .unwrap();
+        assert_eq!(raw, column);
+        let resolved = resolve_descriptor_column(&column, &io, 
BlobReadLimiter::new())
+            .await
+            .unwrap();
+        assert_eq!(resolved.value(0), b"ell");
+        assert!(resolved.is_null(1));
+    }
+
     #[derive(Clone)]
     struct TrackingFileRead {
         bytes: Bytes,
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index f0f435af..3b267121 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -1231,9 +1231,11 @@ async fn resolve_descriptor_columns(
         blob_descriptor_fields,
         limiter.parallelism(),
         |column| {
-        let file_io = file_io.clone();
-        let limiter = limiter.clone();
-        async move { super::blob_resolver::resolve_blob_column(&column, 
&file_io, limiter).await }
+            let file_io = file_io.clone();
+            let limiter = limiter.clone();
+            async move {
+                super::blob_resolver::resolve_descriptor_column(&column, 
&file_io, limiter).await
+            }
         },
     )
     .await
diff --git a/crates/paimon/src/table/data_evolution_writer.rs 
b/crates/paimon/src/table/data_evolution_writer.rs
index cbe0b142..7976bbf3 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -319,7 +319,13 @@ impl DataEvolutionWriter {
         if self.matched_batches.is_empty() {
             return Ok(Vec::new());
         }
-        let read_table = &index.read_table;
+        // Rewriting an inline Blob column must preserve references for rows
+        // without an update. Resolving them would replace descriptors with
+        // payload bytes and unnecessarily require the external files to exist.
+        let read_table = index.read_table.copy_with_options(HashMap::from([(
+            "blob-as-descriptor".to_string(),
+            "true".to_string(),
+        )]));
         let file_index = &index.files;
         if file_index.is_empty() {
             return Err(crate::Error::DataInvalid {
diff --git a/crates/paimon/src/table/data_file_path_factory.rs 
b/crates/paimon/src/table/data_file_path_factory.rs
index 2695ab78..fad9cae3 100644
--- a/crates/paimon/src/table/data_file_path_factory.rs
+++ b/crates/paimon/src/table/data_file_path_factory.rs
@@ -21,12 +21,15 @@ use super::external_path::ExternalPathProvider;
 use crate::spec::{bucket_path_under, data_file_path, relative_bucket_path, 
CoreOptions};
 use crate::Result;
 use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::Mutex;
 
 /// Select a location once per new data/changelog/Blob file. Sidecars use the
 /// selected file's parent; they must never advance the external-path provider.
 pub(super) struct DataFilePathFactory {
     bucket_path: String,
+    file_uuid: uuid::Uuid,
+    file_counter: AtomicU64,
     external: Option<Mutex<ExternalPathProvider>>,
 }
 
@@ -57,6 +60,8 @@ impl DataFilePathFactory {
         let relative = relative_bucket_path(partition, bucket, directory);
         Ok(Self {
             bucket_path,
+            file_uuid: uuid::Uuid::new_v4(),
+            file_counter: AtomicU64::new(0),
             external: ExternalPathProvider::new(options, 
&relative)?.map(Mutex::new),
         })
     }
@@ -65,6 +70,12 @@ impl DataFilePathFactory {
         &self.bucket_path
     }
 
+    /// Java shares the UUID and counter across all physical column files.
+    pub fn new_file_name(&self, prefix: &str, format: &str) -> String {
+        let counter = self.file_counter.fetch_add(1, Ordering::Relaxed);
+        format!("{prefix}{}-{counter}.{format}", self.file_uuid)
+    }
+
     pub fn new_path(&self, name: &str) -> Result<DataFilePath> {
         let external_path = self
             .external
diff --git a/crates/paimon/src/table/data_file_writer.rs 
b/crates/paimon/src/table/data_file_writer.rs
index 62b7da25..a28c7078 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -175,6 +175,7 @@ impl DataFileWriter {
             return Ok(());
         }
 
+        super::inline_blob::validate_inline_blob_columns(batch, 
&self.format_options)?;
         if self.current_writer.is_none() {
             self.open_new_file(batch.schema()).await?;
         }
@@ -202,19 +203,19 @@ impl DataFileWriter {
         Ok(())
     }
 
+    pub(super) fn has_open_file(&self) -> bool {
+        self.current_writer.is_some()
+    }
+
     async fn open_new_file(&mut self, schema: arrow_schema::SchemaRef) -> 
Result<()> {
         let index = self
             .index_options
             .as_ref()
             .map(|options| options.create_writer())
             .transpose()?;
-        let file_name = format!(
-            "{}{}-{}.{}",
-            self.data_file_prefix,
-            uuid::Uuid::new_v4(),
-            self.next_file_ordinal,
-            self.file_format,
-        );
+        let file_name = self
+            .paths
+            .new_file_name(&self.data_file_prefix, &self.file_format);
         let location = self.paths.new_path(&file_name)?;
         let bucket_dir = location.parent();
         self.file_io.mkdirs(&format!("{bucket_dir}/")).await?;
@@ -285,6 +286,13 @@ impl DataFileWriter {
         let file_source = self.file_source;
         let first_row_id = self.first_row_id;
         let write_cols = self.write_cols.clone();
+        // Java creates a new row sequence counter for each physical DE file.
+        let max_sequence_number = if 
CoreOptions::new(&self.format_options).data_evolution_enabled()
+        {
+            row_count - 1
+        } else {
+            0
+        };
 
         Some(async move {
             let write_result = writer.close().await?;
@@ -298,6 +306,7 @@ impl DataFileWriter {
                 write_cols,
                 write_result.value_stats,
             );
+            meta.max_sequence_number = max_sequence_number;
             meta.external_path = location.external_path;
             if let Some(index) = index {
                 let bytes = index.serialize()?;
@@ -355,11 +364,7 @@ impl DataFileWriter {
             for (writer, result) in results {
                 match result {
                     Ok(files) => {
-                        for file in files {
-                            for path in 
file.collect_files(writer.bucket_dir()) {
-                                let _ = 
writer.file_io.delete_file(&path).await;
-                            }
-                        }
+                        writer.delete_files(&files).await;
                     }
                     Err(error) => {
                         first_error.get_or_insert(error);
@@ -411,6 +416,14 @@ impl DataFileWriter {
         self.written_files.clear();
     }
 
+    pub(super) async fn delete_files(&mut self, files: &[DataFileMeta]) {
+        for file in files {
+            for path in file.collect_files(self.bucket_dir()) {
+                let _ = self.file_io.delete_file(&path).await;
+            }
+        }
+    }
+
     fn bucket_dir(&self) -> &str {
         self.paths.bucket_path()
     }
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs 
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 7fcc2d2b..77b24e23 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -17,10 +17,10 @@
 
 use crate::io::FileIO;
 use crate::resource::ResourceContext;
-use crate::spec::{BlobViewStruct, DataField, DataFileMeta, DataType};
+use crate::spec::{CoreOptions, DataField, DataFileMeta, DataType};
 use crate::table::data_file_writer::DataFileWriter;
 use crate::Result;
-use arrow_array::{Array, RecordBatch};
+use arrow_array::RecordBatch;
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
@@ -57,7 +57,8 @@ pub(crate) struct AppendDedicatedFormatFileWriter {
     vector_writer: Option<VectorFieldWriter>,
     normal_column_indices: Vec<usize>,
     normal_schema: Arc<arrow_schema::Schema>,
-    blob_view_column_indices: Vec<(usize, String)>,
+    // Completed normal + dedicated file groups, still owned until 
prepare_commit.
+    written_files: Vec<DataFileMeta>,
 }
 
 impl AppendDedicatedFormatFileWriter {
@@ -81,7 +82,6 @@ impl AppendDedicatedFormatFileWriter {
         table_fields: &[DataField],
         format_options: &HashMap<String, String>,
         blob_inline_fields: &HashSet<String>,
-        blob_view_fields: &HashSet<String>,
     ) -> Result<Self> {
         let paths = 
Arc::new(super::data_file_path_factory::DataFilePathFactory::new(
             &table_location,
@@ -179,6 +179,20 @@ impl AppendDedicatedFormatFileWriter {
             None
         };
 
+        let core_options = CoreOptions::new(format_options);
+        // Full append writes include all normal fields in schema order. 
Partial
+        // column writers must retain their explicit write_cols instead.
+        let normal_write_cols = if core_options.data_evolution_enabled()
+            && core_options.data_evolution_write_cols_optimization_enabled()
+        {
+            None
+        } else {
+            Some(normal_field_names)
+        };
+        let normal_index =
+            
super::data_file_index_writer::FileIndexOptions::parse(format_options, 
table_fields)?
+                .and_then(|options| 
options.project_to_fields(&normal_table_fields))
+                .map(Arc::new);
         let normal_writer = DataFileWriter::new(
             file_io.clone(),
             table_location,
@@ -194,23 +208,19 @@ impl AppendDedicatedFormatFileWriter {
             format_options.clone(),
             Some(0),
             None,
-            Some(normal_field_names),
+            normal_write_cols,
         )?
         .with_path_factory(paths.clone())
+        .with_file_index(normal_index)
         .with_target_file_row_num(target_file_row_num);
 
         Ok(Self {
+            written_files: Vec::new(),
             normal_writer,
             blob_writers,
             vector_writer,
             normal_column_indices,
             normal_schema,
-            blob_view_column_indices: table_fields
-                .iter()
-                .enumerate()
-                .filter(|(_, field)| blob_view_fields.contains(field.name()))
-                .map(|(idx, field)| (idx, field.name().to_string()))
-                .collect(),
         })
     }
 
@@ -230,8 +240,6 @@ impl AppendDedicatedFormatFileWriter {
             return Ok(());
         }
 
-        self.validate_blob_view_columns(batch)?;
-
         // Write normal columns
         let normal_columns: Vec<Arc<dyn arrow_array::Array>> = self
             .normal_column_indices
@@ -260,7 +268,11 @@ impl AppendDedicatedFormatFileWriter {
                     ),
                     source: None,
                 })?;
-            blob_writer.writer.write(&blob_batch).await?;
+            // Serialized descriptor size says nothing about the payload size.
+            // Java checks the physical Blob bytes after every row.
+            for row in 0..blob_batch.num_rows() {
+                blob_writer.writer.write(&blob_batch.slice(row, 1)).await?;
+            }
         }
 
         if let Some(vector_writer) = &mut self.vector_writer {
@@ -285,51 +297,15 @@ impl AppendDedicatedFormatFileWriter {
             vector_writer.writer.write(&vector_batch).await?;
         }
 
-        Ok(())
-    }
-
-    fn validate_blob_view_columns(&self, batch: &RecordBatch) -> Result<()> {
-        for (column_index, field_name) in &self.blob_view_column_indices {
-            let Some(col) = batch
-                .column(*column_index)
-                .as_any()
-                .downcast_ref::<arrow_array::LargeBinaryArray>()
-            else {
-                return Err(crate::Error::DataInvalid {
-                    message: format!(
-                        "blob-view-field '{field_name}' requires a 
LargeBinaryArray value column"
-                    ),
-                    source: None,
-                });
-            };
-            for row in 0..col.len() {
-                if col.is_null(row) {
-                    continue;
-                }
-                let value = col.value(row);
-                if !BlobViewStruct::is_blob_view_struct(value) {
-                    return Err(crate::Error::DataInvalid {
-                        message: format!(
-                            "blob-view-field '{field_name}' requires blob 
field value to be a serialized BlobViewStruct"
-                        ),
-                        source: None,
-                    });
-                }
-                let view = BlobViewStruct::deserialize(value)?;
-                if view.serialize()?.as_slice() != value {
-                    return Err(crate::Error::DataInvalid {
-                        message: format!(
-                            "blob-view-field '{field_name}' contains a 
non-canonical BlobViewStruct payload"
-                        ),
-                        source: None,
-                    });
-                }
-            }
+        if !self.normal_writer.has_open_file() {
+            self.close_group().await?;
         }
         Ok(())
     }
 
     pub(crate) async fn abort(&mut self) {
+        self.normal_writer.delete_files(&self.written_files).await;
+        self.written_files.clear();
         self.normal_writer.abort().await;
         for writer in &mut self.blob_writers {
             writer.writer.abort().await;
@@ -340,6 +316,11 @@ impl AppendDedicatedFormatFileWriter {
     }
 
     pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>> 
{
+        self.close_group().await?;
+        Ok(std::mem::take(&mut self.written_files))
+    }
+
+    async fn close_group(&mut self) -> Result<()> {
         let writers = std::iter::once(&mut self.normal_writer)
             .chain(
                 self.blob_writers
@@ -351,11 +332,14 @@ impl AppendDedicatedFormatFileWriter {
                     .iter_mut()
                     .map(|writer| &mut writer.writer),
             );
-        Ok(DataFileWriter::prepare_group(writers)
-            .await?
-            .into_iter()
-            .flatten()
-            .collect())
+        match DataFileWriter::prepare_group(writers).await {
+            Ok(files) => 
self.written_files.extend(files.into_iter().flatten()),
+            Err(error) => {
+                self.abort().await;
+                return Err(error);
+            }
+        }
+        Ok(())
     }
 }
 
@@ -368,7 +352,7 @@ mod tests {
 
     #[tokio::test]
     async fn failed_blob_close_cleans_every_physical_column() {
-        for external in [false, true] {
+        for (external, rolled) in [(false, false), (true, false), (false, 
true), (true, true)] {
             let io = FileIOBuilder::new("memory").build().unwrap();
             let batch = RecordBatch::try_from_iter([
                 ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
@@ -387,7 +371,7 @@ mod tests {
                 DataField::new(1, "a".into(), DataType::Blob(BlobType::new())),
                 DataField::new(2, "b".into(), DataType::Blob(BlobType::new())),
             ];
-            let options = if external {
+            let mut options = if external {
                 HashMap::from([
                     (
                         "data-file.external-paths".into(),
@@ -401,6 +385,11 @@ mod tests {
             } else {
                 HashMap::new()
             };
+            options.extend([
+                ("file-index.bloom-filter.columns".into(), "id".into()),
+                ("file-index.bloom-filter.id.items".into(), "10".into()),
+                ("file-index.in-manifest-threshold".into(), "0 B".into()),
+            ]);
             let mut writer = AppendDedicatedFormatFileWriter::new(
                 io.clone(),
                 "memory:/table".into(),
@@ -408,7 +397,7 @@ mod tests {
                 0,
                 0,
                 i64::MAX,
-                i64::MAX,
+                if rolled { 1 } else { i64::MAX },
                 i64::MAX,
                 "none".into(),
                 0,
@@ -420,17 +409,19 @@ mod tests {
                 &fields,
                 &options,
                 &HashSet::new(),
-                &HashSet::new(),
             )
             .unwrap();
             writer.write(&batch).await.unwrap();
             writer.blob_writers[1].writer.inject_close_failure();
-            assert!(writer
-                .prepare_commit()
-                .await
-                .unwrap_err()
-                .to_string()
-                .contains("injected close failure"));
+            let error = if rolled {
+                assert!(!writer.written_files.is_empty());
+                // A later group failure must also remove earlier successful
+                // groups, which have not been handed to a committer yet.
+                writer.write(&batch).await.unwrap_err()
+            } else {
+                writer.prepare_commit().await.unwrap_err()
+            };
+            assert!(error.to_string().contains("injected close failure"));
             assert!(io
                 .list_status_recursive("memory:/")
                 .await
diff --git a/crates/paimon/src/table/inline_blob.rs 
b/crates/paimon/src/table/inline_blob.rs
new file mode 100644
index 00000000..1cd68c97
--- /dev/null
+++ b/crates/paimon/src/table/inline_blob.rs
@@ -0,0 +1,71 @@
+// 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.
+
+//! Validate inline Blob values before any physical file is created.
+
+use crate::spec::{BlobDescriptor, BlobViewStruct, CoreOptions};
+use crate::{Error, Result};
+use arrow_array::{LargeBinaryArray, RecordBatch};
+use std::collections::HashMap;
+
+pub(super) fn validate_inline_blob_columns(
+    batch: &RecordBatch,
+    options: &HashMap<String, String>,
+) -> Result<()> {
+    if !options.contains_key("blob-descriptor-field") && 
!options.contains_key("blob-view-field") {
+        return Ok(());
+    }
+    let options = CoreOptions::new(options);
+    for (option, fields) in [
+        ("blob-descriptor-field", options.blob_descriptor_fields()),
+        ("blob-view-field", options.blob_view_fields()),
+    ] {
+        for field in fields {
+            // A partial-column update may not contain this field.
+            let Some(column) = batch.column_by_name(&field) else {
+                continue;
+            };
+            let invalid = |message: String| Error::DataInvalid {
+                message: format!("{option} '{field}': {message}"),
+                source: None,
+            };
+            let column = column
+                .as_any()
+                .downcast_ref::<LargeBinaryArray>()
+                .ok_or_else(|| invalid("requires a LargeBinaryArray".into()))?;
+            for value in column.iter().flatten() {
+                if option == "blob-descriptor-field" {
+                    // Schema-declared descriptors use Java's prefix parser;
+                    // unlike detection in raw payloads, trailing bytes are 
valid.
+                    BlobDescriptor::deserialize(value).map_err(|error| {
+                        invalid(format!("requires a serialized BlobDescriptor: 
{error}"))
+                    })?;
+                } else {
+                    if !BlobViewStruct::is_blob_view_struct(value) {
+                        return Err(invalid("requires a serialized 
BlobViewStruct".into()));
+                    }
+                    let view = BlobViewStruct::deserialize(value)
+                        .map_err(|error| invalid(error.to_string()))?;
+                    if view.serialize()?.as_slice() != value {
+                        return Err(invalid("non-canonical BlobViewStruct 
payload".into()));
+                    }
+                }
+            }
+        }
+    }
+    Ok(())
+}
diff --git a/crates/paimon/src/table/managed_blob_reader.rs 
b/crates/paimon/src/table/managed_blob_reader.rs
index 6cdaf808..0d311f95 100644
--- a/crates/paimon/src/table/managed_blob_reader.rs
+++ b/crates/paimon/src/table/managed_blob_reader.rs
@@ -17,7 +17,7 @@
 
 //! Resolve BLOB descriptors after primary-key merge and row selection.
 
-use super::blob_resolver::{resolve_blob_column, BlobReadLimiter};
+use super::blob_resolver::{resolve_descriptor_column, BlobReadLimiter};
 use super::managed_blob_writer::{managed_blob_kind, ManagedBlobKind};
 use super::{ArrowRecordBatchStream, Table};
 use crate::arrow::format::FilePredicates;
@@ -195,7 +195,8 @@ async fn resolve_batch(
         let column = match kind {
             ManagedBlobKind::Scalar => {
                 let values = blob_values(columns[index].as_ref())?;
-                Arc::new(resolve_blob_column(values, file_io, 
limiter.clone()).await?) as ArrayRef
+                Arc::new(resolve_descriptor_column(values, file_io, 
limiter.clone()).await?)
+                    as ArrayRef
             }
             ManagedBlobKind::Array => {
                 let array = columns[index]
@@ -205,7 +206,7 @@ async fn resolve_batch(
                 let values = blob_values(array.values().as_ref())?;
                 let visible = visible_child_values(values, 
array.value_offsets(), array)?;
                 let resolved =
-                    Arc::new(resolve_blob_column(&visible, file_io, 
limiter.clone()).await?);
+                    Arc::new(resolve_descriptor_column(&visible, file_io, 
limiter.clone()).await?);
                 let ArrowDataType::List(element) = array.data_type() else {
                     unreachable!()
                 };
@@ -227,7 +228,7 @@ async fn resolve_batch(
                 let values = blob_values(map.entries().column(1).as_ref())?;
                 let visible = visible_child_values(values, 
map.value_offsets(), map)?;
                 let resolved =
-                    Arc::new(resolve_blob_column(&visible, file_io, 
limiter.clone()).await?);
+                    Arc::new(resolve_descriptor_column(&visible, file_io, 
limiter.clone()).await?);
                 let ArrowDataType::Map(entries_field, ordered) = 
map.data_type() else {
                     unreachable!()
                 };
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index e779858e..5bd17835 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -73,6 +73,7 @@ mod global_index_types;
 mod hybrid_search_builder;
 mod incremental_scan;
 pub(crate) mod index_file_path;
+mod inline_blob;
 mod kv_file_reader;
 mod kv_file_writer;
 mod lumina_index_build_builder;
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 80f84d4a..22a42356 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -460,11 +460,9 @@ impl TableWrite {
                 .any(|f| matches!(f.data_type(), DataType::Vector(_)));
 
         let file_index_options = FileIndexOptions::parse(schema.options(), 
schema.fields())?;
-        if file_index_options.is_some()
-            && (has_blob_fields || has_dedicated_vector_fields || 
!blob_view_fields.is_empty())
-        {
+        if file_index_options.is_some() && has_dedicated_vector_fields {
             return Err(crate::Error::Unsupported {
-                message: "FileIndex generation does not support dedicated 
Blob/Vector writes"
+                message: "FileIndex generation does not support dedicated 
Vector writes"
                     .to_string(),
             });
         }
@@ -617,11 +615,19 @@ impl TableWrite {
 
     pub(super) fn normalize_write_batch(&self, batch: &RecordBatch) -> 
Result<Option<RecordBatch>> {
         let batch = self.validate_write_batch_schema(batch)?;
+        // Java filters row kinds before extracting Blob values. Ignored rows
+        // need neither valid descriptor bytes nor physical files.
+        let batch = self.enrich_rowkind_batch(&batch)?;
         if batch.num_rows() == 0 {
             return Ok(None);
         }
-        let batch = self.enrich_rowkind_batch(&batch)?;
-        Ok((batch.num_rows() != 0).then_some(batch))
+        if !self.primary_key_indices.is_empty() {
+            super::inline_blob::validate_inline_blob_columns(
+                &batch,
+                self.table.schema().options(),
+            )?;
+        }
+        Ok(Some(batch))
     }
 
     pub(super) async fn write_partition_bucket_batch(
@@ -1171,7 +1177,6 @@ impl TableWrite {
                     fields,
                     self.table.schema().options(),
                     &self.blob_inline_fields,
-                    &self.blob_view_fields,
                 )?
                 .with_resources(self.resources.clone()),
             )))
diff --git a/crates/paimon/tests/blob_http_error_test.rs 
b/crates/paimon/tests/blob_http_error_test.rs
new file mode 100644
index 00000000..0fced000
--- /dev/null
+++ b/crates/paimon/tests/blob_http_error_test.rs
@@ -0,0 +1,133 @@
+// 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::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch};
+use axum::{http::StatusCode, response::Redirect, routing::get, Router};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{BlobDescriptor, BlobType, DataType, IntType, Schema, 
TableSchema};
+use paimon::table::BlobReader;
+use std::sync::Arc;
+
+fn assert_redacted(error: &paimon::Error) {
+    let mut rendered = format!("{error}\n{error:?}");
+    let mut source = std::error::Error::source(error);
+    while let Some(cause) = source {
+        rendered.push_str(&format!("\n{cause}\n{cause:?}"));
+        source = cause.source();
+    }
+    for secret in [
+        "original-user",
+        "original-password",
+        "original-token",
+        "original-fragment",
+        "redirect-secret",
+    ] {
+        assert!(
+            !rendered.contains(secret),
+            "HTTP credential leaked: {rendered}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn 
http_errors_redact_direct_and_redirected_credentials_in_public_blob_apis() {
+    let app = Router::new()
+        .route(
+            "/redirect",
+            get(|| async { 
Redirect::temporary("/failure?signature=redirect-secret") }),
+        )
+        .route("/failure", get(|| async { StatusCode::FORBIDDEN }))
+        .route("/short", get(|| async { "x" }));
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let address = listener.local_addr().unwrap();
+    let server = tokio::spawn(async move { axum::serve(listener, 
app).await.unwrap() });
+    for route in ["failure", "redirect", "short"] {
+        for length in [-1, 2] {
+            // A one-byte resource only fails a bounded read, not length=-1.
+            if route == "short" && length == -1 {
+                continue;
+            }
+            for inline in [false, true] {
+                let mut schema = Schema::builder()
+                    .column("id", DataType::Int(IntType::new()))
+                    .column("payload", DataType::Blob(BlobType::new()))
+                    .option("row-tracking.enabled", "true")
+                    .option("data-evolution.enabled", "true");
+                if inline {
+                    schema = schema.option("blob-descriptor-field", "payload");
+                }
+                let path = "memory:/http_failure";
+                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;
+                let uri = if route == "redirect" {
+                    // Credentials exist only on the redirected URL.
+                    format!("http://{address}/redirect";)
+                } else {
+                    
format!("http://original-user:original-password@{address}/{route}?signature=original-token#original-fragment";)
+                };
+                let descriptor = BlobDescriptor::new(uri, 0, 
length).serialize();
+                let error = BlobReader::from_file_io(io.clone())
+                    .read_blobs(std::slice::from_ref(&descriptor))
+                    .await
+                    .unwrap_err();
+                assert_redacted(&error);
+                if route != "short" {
+                    assert!(error.to_string().contains("403"));
+                }
+                let input = RecordBatch::try_from_iter([
+                    ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+                    (
+                        "payload",
+                        
Arc::new(LargeBinaryArray::from(vec![Some(descriptor.as_slice())]))
+                            as ArrayRef,
+                    ),
+                ])
+                .unwrap();
+                let builder = table.new_write_builder();
+                let mut writer = builder.new_write().unwrap();
+                let error = if inline {
+                    writer.write_arrow_batch(&input).await.unwrap();
+                    builder
+                        .new_commit()
+                        .commit(writer.prepare_commit().await.unwrap())
+                        .await
+                        .unwrap();
+                    let read = table.new_read_builder();
+                    let plan = read.new_scan().plan().await.unwrap();
+                    read.new_read()
+                        .unwrap()
+                        .to_arrow(plan.splits())
+                        .unwrap()
+                        .try_collect::<Vec<_>>()
+                        .await
+                        .unwrap_err()
+                } else {
+                    writer.write_arrow_batch(&input).await.unwrap_err()
+                };
+                assert_redacted(&error);
+                if route != "short" {
+                    assert!(error.to_string().contains("403"));
+                }
+            }
+        }
+    }
+    server.abort();
+}
diff --git a/crates/paimon/tests/dedicated_blob_write_test.rs 
b/crates/paimon/tests/dedicated_blob_write_test.rs
new file mode 100644
index 00000000..cdcbf5d1
--- /dev/null
+++ b/crates/paimon/tests/dedicated_blob_write_test.rs
@@ -0,0 +1,556 @@
+// 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::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{BlobDescriptor, BlobType, DataType, IntType, Schema, 
TableSchema};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn table(options: &[(&str, &str)]) -> Table {
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("payload", DataType::Blob(BlobType::new()))
+        .option("row-tracking.enabled", "true")
+        .option("data-evolution.enabled", "true");
+    for (key, value) in options {
+        schema = schema.option(*key, *value);
+    }
+    let path = "memory:/blob_write";
+    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 batch(ids: Vec<i32>, blobs: Vec<Option<&[u8]>>) -> RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+        (
+            "payload",
+            Arc::new(LargeBinaryArray::from(blobs)) as ArrayRef,
+        ),
+    ])
+    .unwrap()
+}
+
+async fn rows(table: &Table) -> Vec<(i32, Option<Vec<u8>>)> {
+    let read = table.new_read_builder();
+    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 mut rows = Vec::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let values = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap();
+        rows.extend(
+            ids.values()
+                .iter()
+                .zip(values.iter())
+                .map(|(id, value)| (*id, value.map(Vec::from))),
+        );
+    }
+    rows.sort_by_key(|row| row.0);
+    rows
+}
+
+#[tokio::test]
+async fn 
partial_blob_updates_preserve_unmatched_descriptors_without_resolving() {
+    let table = table(&[("blob-descriptor-field", "payload")]).await;
+    // None of these references exist. Updating an inline reference must not
+    // read the old or new payloads, including rows retained in the same file.
+    let old = BlobDescriptor::new("memory:/missing/old".into(), 0, 
3).serialize();
+    let retained = BlobDescriptor::new("memory:/missing/retained".into(), 7, 
-1).serialize();
+    let updated = BlobDescriptor::new("memory:/missing/updated".into(), 2, 
5).serialize();
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&batch(
+            vec![1, 2, 3],
+            vec![Some(&old), Some(&retained), None],
+        ))
+        .await
+        .unwrap();
+    builder
+        .new_commit()
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+
+    let mut update = builder
+        .new_data_evolution_writer(vec!["payload".into()])
+        .unwrap();
+    update
+        .add_matched_batch(
+            RecordBatch::try_from_iter([
+                (
+                    "_ROW_ID",
+                    Arc::new(arrow_array::Int64Array::from(vec![0])) as 
ArrayRef,
+                ),
+                (
+                    "payload",
+                    
Arc::new(LargeBinaryArray::from(vec![Some(updated.as_slice())])) as ArrayRef,
+                ),
+            ])
+            .unwrap(),
+        )
+        .unwrap();
+    builder
+        .new_commit()
+        .commit(update.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+
+    let descriptor_table = table.copy_with_options(
+        [("blob-as-descriptor".to_string(), "true".to_string())]
+            .into_iter()
+            .collect(),
+    );
+    assert_eq!(
+        rows(&descriptor_table).await,
+        vec![(1, Some(updated)), (2, Some(retained)), (3, None)]
+    );
+}
+
+#[tokio::test]
+async fn prepared_sequences_and_optimized_write_columns_match_java() {
+    for optimize in [false, true] {
+        let table = table(&[(
+            "data-evolution.write-cols-optimization.enabled",
+            if optimize { "true" } else { "false" },
+        )])
+        .await;
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .write_arrow_batch(&batch(vec![1, 2, 3], vec![Some(b"one"), None, 
Some(b"")]))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        for file in &messages[0].new_files {
+            assert_eq!((file.min_sequence_number, file.max_sequence_number), 
(0, 2));
+            let expected = if file.file_name.ends_with(".blob") {
+                Some(vec!["payload".to_string()])
+            } else if optimize {
+                None
+            } else {
+                Some(vec!["id".to_string()])
+            };
+            assert_eq!(file.write_cols, expected);
+        }
+        builder.new_commit().commit(messages).await.unwrap();
+        assert_eq!(
+            rows(&table).await,
+            vec![(1, Some(b"one".to_vec())), (2, None), (3, Some(vec![]))]
+        );
+    }
+}
+
+#[tokio::test]
+async fn normal_rolls_keep_blob_groups_and_row_ids_aligned() {
+    let table = table(&[("target-file-row-num", "2")]).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    for (ids, blobs) in [
+        (vec![1, 2], vec![Some(b"a".as_slice()), None]),
+        (
+            vec![3, 4],
+            vec![Some(b"b".as_slice()), Some(b"".as_slice())],
+        ),
+    ] {
+        writer.write_arrow_batch(&batch(ids, blobs)).await.unwrap();
+    }
+    let messages = writer.prepare_commit().await.unwrap();
+    let files = &messages[0].new_files;
+    assert_eq!(
+        files
+            .iter()
+            .map(|file| file.file_name.ends_with(".blob"))
+            .collect::<Vec<_>>(),
+        vec![false, true, false, true]
+    );
+    builder.new_commit().commit(messages).await.unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![
+            (1, Some(b"a".to_vec())),
+            (2, None),
+            (3, Some(b"b".to_vec())),
+            (4, Some(vec![]))
+        ]
+    );
+}
+
+#[tokio::test]
+async fn blob_size_rolls_inside_a_batch_using_resolved_payload_bytes() {
+    for descriptor in [false, true] {
+        let table = table(&[("blob.target-file-size", "32 B")]).await;
+        let payload = vec![b'x'; 40];
+        let source = "memory:/source/payload";
+        table
+            .file_io()
+            .new_output(source)
+            .unwrap()
+            .write(payload.clone().into())
+            .await
+            .unwrap();
+        let input = if descriptor {
+            BlobDescriptor::new(source.into(), 0, 40).serialize()
+        } else {
+            payload.clone()
+        };
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        writer
+            .write_arrow_batch(&batch(
+                vec![1, 2, 3],
+                vec![Some(&input), None, Some(&input)],
+            ))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        let blobs: Vec<_> = messages[0]
+            .new_files
+            .iter()
+            .filter(|file| file.file_name.ends_with(".blob"))
+            .collect();
+        assert_eq!(
+            blobs.iter().map(|file| file.row_count).collect::<Vec<_>>(),
+            vec![1, 2]
+        );
+        let prefix = blobs[0].file_name.rsplit_once('-').unwrap().0;
+        for file in &blobs {
+            assert_eq!(file.file_name.rsplit_once('-').unwrap().0, prefix);
+        }
+        assert_ne!(blobs[0].file_name, blobs[1].file_name);
+        builder.new_commit().commit(messages).await.unwrap();
+        assert_eq!(
+            rows(&table).await,
+            vec![(1, Some(payload.clone())), (2, None), (3, Some(payload))]
+        );
+    }
+}
+
+#[tokio::test]
+async fn inline_descriptors_reject_payload_bytes_before_creating_files() {
+    let table = table(&[("blob-descriptor-field", "payload")]).await;
+    let mut writer = table.new_write_builder().new_write().unwrap();
+    let err = writer
+        .write_arrow_batch(&batch(vec![1], vec![Some(b"raw payload")]))
+        .await
+        .unwrap_err();
+    assert!(err.to_string().contains("blob-descriptor-field"), "{err}");
+    assert!(table
+        .file_io()
+        .list_status_recursive("memory:/blob_write/data")
+        .await
+        .unwrap()
+        .iter()
+        .all(|entry| !entry.path.ends_with(".parquet")));
+}
+
+#[tokio::test]
+async fn multiple_blob_columns_keep_values_across_independent_rolls() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("large", DataType::Blob(BlobType::new()))
+        .column("small", DataType::Blob(BlobType::new()))
+        .option("row-tracking.enabled", "true")
+        .option("data-evolution.enabled", "true")
+        .option("target-file-row-num", "2")
+        .option("blob.target-file-size", "32 B")
+        .option("file-index.bloom-filter.columns", "id")
+        .option("file-index.bloom-filter.id.items", "10")
+        .option("file-index.in-manifest-threshold", "0 B")
+        .build()
+        .unwrap();
+    let path = "memory:/two_blobs";
+    let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, table.schema()).await;
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    let mut expected = Vec::new();
+    for start in [0, 2, 4] {
+        let payload = vec![start as u8; 40];
+        let input = RecordBatch::try_from_iter([
+            (
+                "id",
+                Arc::new(Int32Array::from(vec![start, start + 1])) as ArrayRef,
+            ),
+            (
+                "large",
+                Arc::new(LargeBinaryArray::from(vec![Some(payload.as_slice()), 
None])) as ArrayRef,
+            ),
+            (
+                "small",
+                Arc::new(LargeBinaryArray::from(vec![
+                    Some(b"".as_slice()),
+                    Some(b"abc".as_slice()),
+                ])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        writer.write_arrow_batch(&input).await.unwrap();
+        expected.extend([
+            (start, Some(payload), Some(Vec::new())),
+            (start + 1, None, Some(b"abc".to_vec())),
+        ]);
+    }
+    let messages = writer.prepare_commit().await.unwrap();
+    let files = &messages[0].new_files;
+    let prefix = files[0].file_name.rsplit_once('-').unwrap().0;
+    let mut suffixes = std::collections::HashSet::new();
+    for file in files {
+        let (file_prefix, suffix) = file.file_name.rsplit_once('-').unwrap();
+        assert_eq!(file_prefix, prefix);
+        assert!(suffixes.insert(suffix.split('.').next().unwrap()));
+        if file.file_name.ends_with(".parquet") {
+            assert_eq!(file.extra_files.len(), 1);
+        }
+    }
+    builder.new_commit().commit(messages).await.unwrap();
+    let read = table.new_read_builder();
+    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 mut actual = Vec::new();
+    for batch in batches {
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let large = batch
+            .column(1)
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap();
+        let small = batch
+            .column(2)
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap();
+        actual.extend(
+            ids.values()
+                .iter()
+                .zip(large.iter())
+                .zip(small.iter())
+                .map(|((id, large), small)| (*id, large.map(Vec::from), 
small.map(Vec::from))),
+        );
+    }
+    actual.sort_by_key(|row| row.0);
+    assert_eq!(actual, expected);
+}
+
+#[tokio::test]
+async fn 
inline_descriptors_accept_v1_v2_and_null_but_reject_malformed_values() {
+    let table = table(&[("blob-descriptor-field", "payload")]).await;
+    let source = "memory:/source/payload";
+    table
+        .file_io()
+        .new_output(source)
+        .unwrap()
+        .write(b"hello".to_vec().into())
+        .await
+        .unwrap();
+    let v2 = BlobDescriptor::new(source.into(), 0, 5).serialize();
+    let mut v1 = vec![1];
+    v1.extend_from_slice(&v2[9..]);
+    v1.extend_from_slice(b"padding");
+    let mut v2 = v2;
+    v2.extend_from_slice(b"padding");
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer
+        .write_arrow_batch(&batch(vec![1, 2, 3], vec![Some(&v1), Some(&v2), 
None]))
+        .await
+        .unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    assert_eq!(messages[0].new_files.len(), 1);
+    builder.new_commit().commit(messages).await.unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![
+            (1, Some(b"hello".to_vec())),
+            (2, Some(b"hello".to_vec())),
+            (3, None)
+        ]
+    );
+    let mut bad_version = v1;
+    bad_version[0] = 3;
+    for invalid in [
+        vec![],
+        b"payload".to_vec(),
+        v2[..v2.len() - 8].to_vec(),
+        bad_version,
+    ] {
+        let mut writer = builder.new_write().unwrap();
+        assert!(writer
+            .write_arrow_batch(&batch(vec![4], vec![Some(&invalid)]))
+            .await
+            .is_err());
+    }
+    assert_eq!(rows(&table).await.len(), 3);
+}
+
+#[tokio::test]
+async fn primary_key_inline_descriptors_validate_and_resolve_legacy_bytes() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::with_nullable(false)))
+        .column("payload", DataType::Blob(BlobType::new()))
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("blob-descriptor-field", "payload")
+        .build()
+        .unwrap();
+    let path = "memory:/pk_inline_blob";
+    let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, table.schema()).await;
+    let source = "memory:/source/payload";
+    io.new_output(source)
+        .unwrap()
+        .write(b"hello".to_vec().into())
+        .await
+        .unwrap();
+    let v2 = BlobDescriptor::new(source.into(), 0, 5).serialize();
+    let mut v1 = vec![1];
+    v1.extend_from_slice(&v2[9..]);
+    v1.extend_from_slice(b"padding");
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    let error = writer
+        .write_arrow_batch(&batch(vec![1], vec![Some(b"raw")]))
+        .await
+        .unwrap_err();
+    assert!(error.to_string().contains("blob-descriptor-field"));
+    writer
+        .write_arrow_batch(&batch(vec![1, 2, 3], vec![Some(&v1), Some(&v2), 
None]))
+        .await
+        .unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+    assert_eq!(
+        rows(&table).await,
+        vec![
+            (1, Some(b"hello".to_vec())),
+            (2, Some(b"hello".to_vec())),
+            (3, None)
+        ]
+    );
+}
+
+#[tokio::test]
+async fn ignored_row_kinds_skip_inline_blob_validation() {
+    for generated in [false, true] {
+        let mut schema = Schema::builder()
+            .column("id", DataType::Int(IntType::with_nullable(false)))
+            .column("payload", DataType::Blob(BlobType::new()))
+            .primary_key(["id"])
+            .option("bucket", "1")
+            .option("ignore-delete", "true")
+            .option("ignore-update-before", "true")
+            .option("blob-descriptor-field", "payload");
+        if generated {
+            schema = schema
+                .column(
+                    "kind",
+                    
DataType::VarChar(paimon::spec::VarCharType::string_type()),
+                )
+                .option("rowkind.field", "kind");
+        }
+        let path = "memory:/ignored_inline_blob";
+        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;
+        io.new_output("memory:/payload")
+            .unwrap()
+            .write(b"hello".to_vec().into())
+            .await
+            .unwrap();
+        let descriptor = BlobDescriptor::new("memory:/payload".into(), 0, 
5).serialize();
+        let input = batch(
+            vec![1, 2, 3],
+            vec![Some(&descriptor), Some(b""), Some(b"raw")],
+        );
+        let mut columns: Vec<(String, ArrayRef)> = input
+            .schema()
+            .fields()
+            .iter()
+            .zip(input.columns())
+            .map(|(field, column)| (field.name().clone(), column.clone()))
+            .collect();
+        if generated {
+            columns.push((
+                "kind".into(),
+                Arc::new(arrow_array::StringArray::from(vec!["+I", "-D", 
"-U"])),
+            ));
+        } else {
+            columns.push((
+                "_VALUE_KIND".into(),
+                Arc::new(arrow_array::Int8Array::from(vec![0, 3, 1])),
+            ));
+        }
+        let input = RecordBatch::try_from_iter(columns).unwrap();
+        let builder = table.new_write_builder();
+        let mut writer = builder.new_write().unwrap();
+        // An all-ignored batch creates no files, even with invalid payloads.
+        writer.write_arrow_batch(&input.slice(1, 2)).await.unwrap();
+        assert!(writer.prepare_commit().await.unwrap().is_empty());
+        writer.write_arrow_batch(&input).await.unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        builder.new_commit().commit(messages).await.unwrap();
+        assert_eq!(rows(&table).await, vec![(1, Some(b"hello".to_vec()))]);
+        // Filtering must not bypass validation of a retained row.
+        let invalid = input.slice(0, 1);
+        let mut columns = invalid.columns().to_vec();
+        columns[1] = 
Arc::new(LargeBinaryArray::from(vec![Some(b"raw".as_slice())]));
+        let invalid = RecordBatch::try_new(invalid.schema(), columns).unwrap();
+        assert!(builder
+            .new_write()
+            .unwrap()
+            .write_arrow_batch(&invalid)
+            .await
+            .unwrap_err()
+            .to_string()
+            .contains("blob-descriptor-field"));
+    }
+}

Reply via email to