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 f900b02c fix(core): preserve REST Blob view dependency context (#1085)
f900b02c is described below

commit f900b02cd60a3fd04f28fcf8805d73f6892df947
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 17:54:26 2026 +0800

    fix(core): preserve REST Blob view dependency context (#1085)
---
 crates/paimon/src/api/rest_api.rs                |   1 +
 crates/paimon/src/table/data_evolution_reader.rs | 332 +++++++++++++++++------
 crates/paimon/src/table/rest_env.rs              |  86 ++++++
 crates/paimon/src/table/table_read.rs            |  27 +-
 crates/paimon/tests/mock_server.rs               |  31 ++-
 crates/paimon/tests/rest_catalog_test.rs         |  23 +-
 6 files changed, 390 insertions(+), 110 deletions(-)

diff --git a/crates/paimon/src/api/rest_api.rs 
b/crates/paimon/src/api/rest_api.rs
index b2d9a29f..57828e34 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -104,6 +104,7 @@ pub struct RESTApi {
 
 impl RESTApi {
     // Constants for query parameters and headers
+    pub const READ_VIA_HEADER: &'static str = "X-Paimon-Read-Via";
     pub const HEADER_PREFIX: &'static str = "header.";
     pub const MAX_RESULTS: &'static str = "maxResults";
     pub const PAGE_TOKEN: &'static str = "pageToken";
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 933b41b9..b856bbc8 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -820,15 +820,21 @@ impl DataEvolutionReader {
                 .load_missing(rest_env, &batch, &self.blob_view_fields)
                 .await?;
         }
-        batch = self.resolve_blob_view_columns(batch, 
blob_view_lookup.as_ref())?;
+        batch = self
+            .resolve_blob_view_columns(batch, blob_view_lookup.as_ref())
+            .await?;
         let mut batch = if !self.blob_as_descriptor && 
!descriptor_fields.is_empty() {
-            resolve_descriptor_columns(
-                batch,
-                descriptor_fields,
-                &self.file_io,
-                &self.blob_read_limiter,
-            )
-            .await?
+            // View payloads were resolved with their upstream table's FileIO.
+            let fields = if blob_view_lookup.is_some() {
+                descriptor_fields
+                    .difference(&self.blob_view_fields)
+                    .cloned()
+                    .collect()
+            } else {
+                descriptor_fields.clone()
+            };
+            resolve_descriptor_columns(batch, &fields, &self.file_io, 
&self.blob_read_limiter)
+                .await?
         } else {
             batch
         };
@@ -928,7 +934,7 @@ impl DataEvolutionReader {
         fields
     }
 
-    fn resolve_blob_view_columns(
+    async fn resolve_blob_view_columns(
         &self,
         batch: RecordBatch,
         lookup: Option<&BlobViewLookup>,
@@ -937,7 +943,14 @@ impl DataEvolutionReader {
             return Ok(batch);
         };
 
-        replace_blob_view_columns(batch, &self.blob_view_fields, lookup)
+        replace_blob_view_columns(
+            batch,
+            &self.blob_view_fields,
+            lookup,
+            self.blob_as_descriptor,
+            &self.blob_read_limiter,
+        )
+        .await
     }
 
     /// Merge multiple logical sources column-wise for data evolution.
@@ -1381,10 +1394,12 @@ fn collect_blob_view_structs(
     Ok(())
 }
 
-fn replace_blob_view_columns(
+async fn replace_blob_view_columns(
     batch: RecordBatch,
     blob_view_fields: &HashSet<String>,
     lookup: &BlobViewLookup,
+    blob_as_descriptor: bool,
+    limiter: &BlobReadLimiter,
 ) -> crate::Result<RecordBatch> {
     let schema = batch.schema();
     let mut columns: Vec<Arc<dyn arrow_array::Array>> = 
Vec::with_capacity(batch.num_columns());
@@ -1397,10 +1412,11 @@ fn replace_blob_view_columns(
         }
 
         let col = large_binary_column(&batch, idx, field.name())?;
-        let mut builder = arrow_array::builder::LargeBinaryBuilder::new();
-        for row in 0..col.len() {
+        let mut values = vec![None; col.len()];
+        let mut by_table: HashMap<crate::catalog::Identifier, Vec<(usize, 
Vec<u8>)>> =
+            HashMap::new();
+        for (row, slot) in values.iter_mut().enumerate() {
             if col.is_null(row) {
-                builder.append_null();
                 continue;
             }
 
@@ -1416,11 +1432,35 @@ fn replace_blob_view_columns(
             }
             let view_struct = BlobViewStruct::deserialize(value)?;
             match lookup.descriptor(&view_struct)? {
-                None => builder.append_null(),
-                Some(descriptor) => 
builder.append_value(descriptor.serialize()),
+                None => {}
+                Some(descriptor) if blob_as_descriptor => *slot = 
Some(descriptor.serialize()),
+                Some(descriptor) => by_table
+                    .entry(view_struct.identifier().clone())
+                    .or_default()
+                    .push((row, descriptor.serialize())),
+            }
+        }
+        for (identifier, entries) in by_table {
+            let file_io = lookup
+                .file_ios
+                .get(&identifier)
+                .ok_or_else(|| Error::DataInvalid {
+                    message: format!("Missing upstream FileIO for {}", 
identifier.full_name()),
+                    source: None,
+                })?;
+            let descriptors =
+                LargeBinaryArray::from_iter_values(entries.iter().map(|(_, 
value)| value));
+            let payloads = super::blob_resolver::resolve_descriptor_column(
+                &descriptors,
+                file_io,
+                limiter.clone(),
+            )
+            .await?;
+            for ((row, _), value) in entries.into_iter().zip(payloads.iter()) {
+                values[row] = value.map(<[u8]>::to_vec);
             }
         }
-        columns.push(Arc::new(builder.finish()));
+        columns.push(Arc::new(LargeBinaryArray::from_iter(values)));
         changed = true;
     }
 
@@ -1452,6 +1492,8 @@ fn large_binary_column<'a>(
 #[derive(Debug, Default)]
 struct BlobViewLookup {
     descriptors: HashMap<BlobViewStruct, Option<BlobDescriptor>>,
+    file_ios: HashMap<crate::catalog::Identifier, FileIO>,
+    rest_env: Option<RESTEnv>,
 }
 
 /// Resolve view references only after the caller has selected authorized rows.
@@ -1459,14 +1501,17 @@ pub(crate) fn resolve_blob_view_stream(
     stream: ArrowRecordBatchStream,
     fields: HashSet<String>,
     rest_env: RESTEnv,
+    blob_as_descriptor: bool,
+    blob_parallelism: usize,
 ) -> ArrowRecordBatchStream {
     Box::pin(async_stream::try_stream! {
+        let limiter = BlobReadLimiter::with_parallelism(blob_parallelism);
         let mut stream = stream;
         let mut lookup = BlobViewLookup::default();
         while let Some(batch) = stream.next().await {
             let batch = batch?;
             lookup.load_missing(rest_env.clone(), &batch, &fields).await?;
-            yield replace_blob_view_columns(batch, &fields, &lookup)?;
+            yield replace_blob_view_columns(batch, &fields, &lookup, 
blob_as_descriptor, &limiter).await?;
         }
     })
 }
@@ -1482,8 +1527,11 @@ impl BlobViewLookup {
         collect_blob_view_structs(batch, blob_view_fields, &mut view_structs)?;
         view_structs.retain(|view| !self.descriptors.contains_key(view));
         if !view_structs.is_empty() {
-            let loaded = Self::load(rest_env, view_structs).await?;
+            let loaded =
+                Self::load(self.rest_env.clone().unwrap_or(rest_env), 
view_structs).await?;
             self.descriptors.extend(loaded.descriptors);
+            self.file_ios.extend(loaded.file_ios);
+            self.rest_env = loaded.rest_env;
         }
         Ok(())
     }
@@ -1493,101 +1541,106 @@ impl BlobViewLookup {
             return Ok(Self::default());
         }
 
-        let mut by_table_and_field: HashMap<
-            (crate::catalog::Identifier, i32),
-            Vec<BlobViewStruct>,
-        > = HashMap::new();
+        let rest_env = rest_env.for_dependency_reads().await?;
+        let mut by_table: HashMap<crate::catalog::Identifier, 
Vec<BlobViewStruct>> = HashMap::new();
         for view_struct in view_structs {
-            by_table_and_field
-                .entry((view_struct.identifier().clone(), 
view_struct.field_id()))
+            by_table
+                .entry(view_struct.identifier().clone())
                 .or_default()
                 .push(view_struct);
         }
 
-        let mut lookup = Self::default();
-        for ((identifier, field_id), refs) in by_table_and_field {
+        let mut lookup = Self {
+            rest_env: Some(rest_env.clone()),
+            ..Self::default()
+        };
+        for (identifier, refs) in by_table {
             let table = rest_env.get_table(&identifier).await?;
-            let field = table
+            lookup
+                .file_ios
+                .insert(identifier.clone(), table.file_io().clone());
+            let field_ids: HashSet<_> = 
refs.iter().map(BlobViewStruct::field_id).collect();
+            // Java groups fields and row ranges by table. One projected scan
+            // keeps all referenced fields on the same upstream snapshot.
+            let fields: Vec<_> = table
                 .schema()
                 .fields()
                 .iter()
-                .find(|field| field.id() == field_id)
+                .filter(|field| field_ids.contains(&field.id()))
                 .cloned()
-                .ok_or_else(|| Error::DataInvalid {
-                    message: format!(
-                        "Cannot find blob field id {field_id} in upstream 
table {}",
-                        identifier.full_name()
-                    ),
-                    source: None,
-                })?;
-            if !field.data_type().is_blob_type() {
-                return Err(Error::DataInvalid {
-                    message: format!(
-                        "Field id {field_id} in upstream table {} is not a 
BLOB field",
-                        identifier.full_name()
-                    ),
-                    source: None,
-                });
+                .collect();
+            for id in &field_ids {
+                if !fields.iter().any(|field| field.id() == *id) {
+                    return Err(Error::DataInvalid {
+                        message: format!(
+                            "Cannot find blob field id {id} in upstream table 
{}",
+                            identifier.full_name()
+                        ),
+                        source: None,
+                    });
+                }
+            }
+            for field in &fields {
+                if !field.data_type().is_blob_type() {
+                    return Err(Error::DataInvalid {
+                        message: format!(
+                            "Field id {} in upstream table {} is not a BLOB 
field",
+                            field.id(),
+                            identifier.full_name()
+                        ),
+                        source: None,
+                    });
+                }
             }
 
-            let mut options = HashMap::new();
-            options.insert("blob-as-descriptor".to_string(), 
"true".to_string());
-            let table = table.copy_with_options(options);
-            let row_ranges = row_ranges_for_blob_view_refs(&refs);
+            let table = table.copy_with_options(HashMap::from([(
+                "blob-as-descriptor".to_string(),
+                "true".to_string(),
+            )]));
+            let mut read_fields = fields.clone();
+            read_fields.push(crate::spec::row_id_data_field());
             let mut read_builder = table.new_read_builder();
-            read_builder.with_read_type(vec![field.clone(), 
crate::spec::row_id_data_field()]);
-            read_builder.with_row_ranges(row_ranges);
+            read_builder.with_read_type(read_fields);
+            read_builder.with_row_ranges(row_ranges_for_blob_view_refs(&refs));
             let plan = read_builder.new_scan().plan().await?;
-            let read = read_builder.new_read()?;
-            let mut stream = read.to_arrow(plan.splits())?;
+            let mut stream = read_builder.new_read()?.to_arrow(plan.splits())?;
 
             while let Some(batch) = stream.next().await {
                 let batch = batch?;
-                let blob_col = batch
-                    .column(0)
-                    .as_any()
-                    .downcast_ref::<LargeBinaryArray>()
-                    .ok_or_else(|| Error::DataInvalid {
-                        message: format!(
-                            "Upstream blob field '{}' did not read as 
LargeBinaryArray",
-                            field.name()
-                        ),
-                        source: None,
-                    })?;
                 let row_id_col = batch
-                    .column(1)
+                    .column(fields.len())
                     .as_any()
                     .downcast_ref::<Int64Array>()
                     .ok_or_else(|| Error::DataInvalid {
                         message: "Upstream _ROW_ID did not read as 
Int64Array".to_string(),
                         source: None,
                     })?;
-
-                for row in 0..batch.num_rows() {
-                    if row_id_col.is_null(row) {
-                        continue;
-                    }
-                    let view_struct =
-                        BlobViewStruct::new(identifier.clone(), field_id, 
row_id_col.value(row));
-                    if blob_col.is_null(row) {
-                        lookup.descriptors.insert(view_struct, None);
-                        continue;
-                    }
-                    let value = blob_col.value(row);
-                    if !BlobDescriptor::is_blob_descriptor(value) {
-                        return Err(Error::DataInvalid {
-                            message: format!(
-                                "BlobViewStruct {} field_id={} row_id={} 
resolved to non-BlobDescriptor bytes",
-                                identifier.full_name(),
-                                field_id,
-                                row_id_col.value(row)
-                            ),
-                            source: None,
-                        });
+                for (index, field) in fields.iter().enumerate() {
+                    let blob_col = large_binary_column(&batch, index, 
field.name())?;
+                    for row in 0..batch.num_rows() {
+                        if row_id_col.is_null(row) {
+                            continue;
+                        }
+                        let view_struct = BlobViewStruct::new(
+                            identifier.clone(),
+                            field.id(),
+                            row_id_col.value(row),
+                        );
+                        let descriptor = if blob_col.is_null(row) {
+                            None
+                        } else {
+                            let value = blob_col.value(row);
+                            if !BlobDescriptor::is_blob_descriptor(value) {
+                                return Err(Error::DataInvalid {
+                                    message: format!("BlobViewStruct {} 
field_id={} row_id={} resolved to non-BlobDescriptor bytes",
+                                                     identifier.full_name(), 
field.id(), row_id_col.value(row)),
+                                    source: None,
+                                });
+                            }
+                            Some(BlobDescriptor::deserialize(value)?)
+                        };
+                        lookup.descriptors.insert(view_struct, descriptor);
                     }
-                    lookup
-                        .descriptors
-                        .insert(view_struct, 
Some(BlobDescriptor::deserialize(value)?));
                 }
             }
 
@@ -3191,6 +3244,107 @@ mod tests {
     use std::path::{Path, PathBuf};
     use tempfile::tempdir;
 
+    #[tokio::test]
+    async fn 
blob_view_payloads_use_each_upstream_file_io_even_for_the_same_uri() {
+        let uri = "memory:/referenced-payload";
+        let first_io = FileIO::from_path(uri).unwrap().build().unwrap();
+        let second_io = FileIO::from_path(uri).unwrap().build().unwrap();
+        first_io
+            .new_output(uri)
+            .unwrap()
+            .write(Bytes::from_static(b"first"))
+            .await
+            .unwrap();
+        second_io
+            .new_output(uri)
+            .unwrap()
+            .write(Bytes::from_static(b"other"))
+            .await
+            .unwrap();
+        let first = BlobViewStruct::new(Identifier::new("db", "first"), 1, 0);
+        let second = BlobViewStruct::new(Identifier::new("db", "second"), 1, 
0);
+        let null = BlobViewStruct::new(first.identifier().clone(), 1, 1);
+        let descriptor = BlobDescriptor::new(uri.to_string(), 0, 5);
+        let mut lookup = BlobViewLookup::default();
+        lookup
+            .descriptors
+            .insert(first.clone(), Some(descriptor.clone()));
+        lookup
+            .descriptors
+            .insert(second.clone(), Some(descriptor.clone()));
+        lookup.descriptors.insert(null.clone(), None);
+        lookup.file_ios.insert(first.identifier().clone(), first_io);
+        lookup
+            .file_ios
+            .insert(second.identifier().clone(), second_io);
+        let refs = [
+            Some(first.serialize().unwrap()),
+            None,
+            Some(second.serialize().unwrap()),
+            Some(first.serialize().unwrap()),
+            Some(null.serialize().unwrap()),
+        ];
+        let batch = RecordBatch::try_from_iter([(
+            "payload",
+            Arc::new(LargeBinaryArray::from_iter(refs)) as 
arrow_array::ArrayRef,
+        )])
+        .unwrap();
+        let fields = HashSet::from(["payload".to_string()]);
+        let result = replace_blob_view_columns(
+            batch.clone(),
+            &fields,
+            &lookup,
+            false,
+            &BlobReadLimiter::new(),
+        )
+        .await
+        .unwrap();
+        let values = large_binary_column(&result, 0, "payload")
+            .unwrap()
+            .iter()
+            .collect::<Vec<_>>();
+        assert_eq!(
+            values,
+            vec![
+                Some(b"first".as_slice()),
+                None,
+                Some(b"other".as_slice()),
+                Some(b"first".as_slice()),
+                None
+            ]
+        );
+        // Descriptor output must not open payload files.
+        lookup.file_ios.clear();
+        let result = replace_blob_view_columns(
+            batch.clone(),
+            &fields,
+            &lookup,
+            true,
+            &BlobReadLimiter::new(),
+        )
+        .await
+        .unwrap();
+        let expected = descriptor.serialize();
+        assert_eq!(
+            large_binary_column(&result, 0, "payload")
+                .unwrap()
+                .iter()
+                .collect::<Vec<_>>(),
+            vec![
+                Some(expected.as_slice()),
+                None,
+                Some(expected.as_slice()),
+                Some(expected.as_slice()),
+                None
+            ]
+        );
+        let error =
+            replace_blob_view_columns(batch, &fields, &lookup, false, 
&BlobReadLimiter::new())
+                .await
+                .unwrap_err();
+        assert!(error.to_string().contains("Missing upstream FileIO"));
+    }
+
     mod blob_test_utils {
         include!(concat!(env!("CARGO_MANIFEST_DIR"), "/blob_test_utils.rs"));
     }
diff --git a/crates/paimon/src/table/rest_env.rs 
b/crates/paimon/src/table/rest_env.rs
index fe811404..48ad5e19 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -204,6 +204,31 @@ impl RESTEnv {
         &self.uuid
     }
 
+    /// Attach Java's outermost table identity to dependency requests. The
+    /// dependency client is separate so ordinary requests retain their 
headers.
+    pub(crate) async fn for_dependency_reads(&self) -> Result<Self> {
+        let key = format!("{}{}", RESTApi::HEADER_PREFIX, 
RESTApi::READ_VIA_HEADER);
+        let mut options = self.api.options().clone();
+        if options.get(&key).is_some() {
+            return Ok(self.clone());
+        }
+        let identifier =
+            serde_json::to_string(&self.identifier).map_err(|error| 
Error::DataInvalid {
+                message: "Cannot serialize dependency read 
identifier".to_string(),
+                source: Some(Box::new(error)),
+            })?;
+        options.set(
+            key,
+            crate::api::rest_util::RESTUtil::encode_string(&identifier),
+        );
+        let api = Arc::new(RESTApi::new(options.clone(), false).await?);
+        Ok(Self {
+            api,
+            options,
+            ..self.clone()
+        })
+    }
+
     /// Load a table through the same REST catalog environment.
     pub async fn get_table(&self, identifier: &Identifier) -> Result<Table> {
         Self::load_table(
@@ -603,6 +628,67 @@ mod tests {
     use crate::common::CatalogOptions;
     use crate::io::cache::create_local_cache;
 
+    #[tokio::test]
+    async fn 
dependency_reads_preserve_the_outermost_identifier_and_catalog_options() {
+        let table = crate::table::rest_query_auth_table().await;
+        let mut env = table.rest_env().unwrap().clone();
+        env.identifier = Identifier::new("db 空格", "root$branch_dev");
+        let mut options = env.api.options().clone();
+        options.set("prefix", "configured-prefix");
+        options.set("header.X-Custom", "kept");
+        env.api = Arc::new(RESTApi::new(options, false).await.unwrap());
+        // The API has the merged server configuration, while env.options can
+        // still be the original catalog options.
+        let dependency = env.for_dependency_reads().await.unwrap();
+        let key = "header.X-Paimon-Read-Via";
+        assert!(env.api.options().get(key).is_none());
+        assert!(!Arc::ptr_eq(&env.api, &dependency.api));
+        assert_eq!(
+            dependency.api.options().get("prefix").unwrap(),
+            "configured-prefix"
+        );
+        assert_eq!(
+            dependency.api.options().get("header.X-Custom").unwrap(),
+            "kept"
+        );
+        let decoded =
+            
crate::api::RESTUtil::decode_string(dependency.api.options().get(key).unwrap());
+        assert_eq!(
+            decoded,
+            r#"{"database":"db 空格","object":"root$branch_dev"}"#
+        );
+        let mut nested = dependency.clone();
+        nested.identifier = Identifier::new("other", "middle");
+        let nested = nested.for_dependency_reads().await.unwrap();
+        assert!(Arc::ptr_eq(&dependency.api, &nested.api));
+        assert_eq!(
+            nested.api.options().get(key),
+            dependency.api.options().get(key)
+        );
+    }
+
+    #[tokio::test]
+    async fn dependency_reads_preserve_an_explicit_read_via() {
+        let table = crate::table::rest_query_auth_table().await;
+        let mut env = table.rest_env().unwrap().clone();
+        let mut options = env.api.options().clone();
+        options.set(
+            "header.X-Paimon-Read-Via",
+            "an-already-encoded-outermost-table",
+        );
+        env.api = Arc::new(RESTApi::new(options, false).await.unwrap());
+        let dependency = env.for_dependency_reads().await.unwrap();
+        assert!(Arc::ptr_eq(&env.api, &dependency.api));
+        assert_eq!(
+            dependency
+                .api
+                .options()
+                .get("header.X-Paimon-Read-Via")
+                .unwrap(),
+            "an-already-encoded-outermost-table"
+        );
+    }
+
     #[tokio::test]
     async fn test_rest_env_clones_catalog_local_cache() {
         let cache_directory = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index 71c3d708..183984bd 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -1192,36 +1192,29 @@ impl<'a> PaimonTableRead<'a> {
                 .intersection(&view_fields)
                 .cloned()
                 .collect();
-        let resolve_predicate_views =
-            resolve_views && core_options.blob_as_descriptor() && 
!predicate_views.is_empty();
+        let resolve_predicate_views = resolve_views && 
!predicate_views.is_empty();
         let mut output_views = view_fields.clone();
         if resolve_predicate_views {
             output_views.retain(|field| !predicate_views.contains(field));
         }
-        // Descriptor mode still translates view references to upstream
-        // descriptors. Query predicates must see those logical values.
+        // User predicates see resolved descriptors or payloads, after 
authorization.
         let stream = match self.table.rest_env().filter(|_| 
resolve_predicate_views) {
             Some(env) => 
super::data_evolution_reader::resolve_blob_view_stream(
                 stream,
                 predicate_views,
                 env.clone(),
+                core_options.blob_as_descriptor(),
+                self.blob_parallelism,
             ),
             None => stream,
         };
-        let mut payload_options = self.table.schema().options().clone();
-        if resolve_views {
-            let mut descriptors = core_options.blob_descriptor_fields();
-            descriptors.extend(view_fields.iter().cloned());
-            let mut descriptors: Vec<_> = descriptors.into_iter().collect();
-            descriptors.sort_unstable();
-            payload_options.insert("blob-descriptor-field".to_string(), 
descriptors.join(","));
-        }
-        let payload_options = CoreOptions::new(&payload_options);
+        // Resolving views owns their upstream FileIO, including REST tokens.
+        // The ordinary Blob plan must not decode those payloads a second time.
         let blob_plan = ManagedBlobReadPlan::new(
             &self.read_type,
             &self.data_predicates,
             self.table.schema().fields(),
-            &payload_options,
+            core_options,
         );
         let stream = match &blob_plan {
             Some(plan) => {
@@ -1276,13 +1269,15 @@ impl<'a> PaimonTableRead<'a> {
                 stream,
                 output_views,
                 env.clone(),
+                core_options.blob_as_descriptor(),
+                self.blob_parallelism,
             ),
             None => stream,
         };
         Ok(match blob_plan {
             Some(plan) => plan.finish(
                 stream,
-                &payload_options,
+                core_options,
                 self.table.file_io.clone(),
                 self.blob_parallelism,
                 self.limit,
@@ -1290,7 +1285,7 @@ impl<'a> PaimonTableRead<'a> {
             None => resolve_primary_key_blob_stream(
                 stream,
                 &self.read_type,
-                &payload_options,
+                core_options,
                 self.table.file_io.clone(),
                 self.blob_parallelism,
                 None,
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index fd2082ac..ed127c0d 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -22,7 +22,7 @@
 
 use axum::{
     extract::{Extension, Json, Path, Query},
-    http::StatusCode,
+    http::{HeaderMap, StatusCode},
     response::IntoResponse,
     routing::{get, post},
     serve, Router,
@@ -70,6 +70,7 @@ struct MockState {
     list_page_size: Option<usize>,
     no_permission_databases: HashSet<String>,
     no_permission_tables: HashSet<String>,
+    table_read_via: HashMap<String, String>,
     create_partitions_calls: Vec<(String, String, CreatePartitionsRequest)>,
     drop_partitions_calls: Vec<(String, String, DropPartitionsRequest)>,
     create_partitions_error_status: Option<StatusCode>,
@@ -817,12 +818,31 @@ impl RESTServer {
 
     /// Handle GET /databases/:db/tables/:table - get a specific table.
     pub async fn get_table(
+        headers: HeaderMap,
         Path((db, table)): Path<(String, String)>,
         Extension(state): Extension<Arc<RESTServer>>,
     ) -> impl IntoResponse {
         let mut s = state.inner.lock().unwrap();
 
         let key = format!("{db}.{table}");
+        if let Some(expected) = s.table_read_via.get(&key) {
+            if headers
+                .get("X-Paimon-Read-Via")
+                .and_then(|value| value.to_str().ok())
+                != Some(expected)
+            {
+                return (
+                    StatusCode::FORBIDDEN,
+                    Json(ErrorResponse::new(
+                        Some("table".to_string()),
+                        Some(table.clone()),
+                        Some("Dependency requires Read-Via".to_string()),
+                        Some(403),
+                    )),
+                )
+                    .into_response();
+            }
+        }
         if s.no_permission_tables.contains(&key) {
             let err = ErrorResponse::new(
                 Some("table".to_string()),
@@ -1872,6 +1892,15 @@ impl RESTServer {
             .create_partitions_statistics_error_status = status;
     }
 
+    /// Require the dependency identity on get-table requests for this table.
+    pub fn require_table_read_via(&self, database: &str, table: &str, 
read_via: String) {
+        self.inner
+            .lock()
+            .unwrap()
+            .table_read_via
+            .insert(format!("{database}.{table}"), read_via);
+    }
+
     /// Make the list-partitions endpoint return the given status.
     pub fn set_list_partitions_error_status(&self, status: Option<StatusCode>) 
{
         self.inner.lock().unwrap().list_partitions_error_status = status;
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 927f4136..36c1a35f 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -905,6 +905,19 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
         .await
         .unwrap();
 
+    // This is payload, not another reference to decode. Both the ordinary
+    // and authorized view pipelines below must return these bytes unchanged.
+    let first_payload =
+        
paimon::spec::BlobDescriptor::new("memory:/not-an-upstream-payload".to_string(),
 0, 4)
+            .serialize();
+    let payload_path = tmp.path().join("descriptor-payload");
+    std::fs::write(&payload_path, &first_payload).unwrap();
+    let first_input = paimon::spec::BlobDescriptor::new(
+        format!("file://{}", payload_path.display()),
+        0,
+        first_payload.len() as i64,
+    )
+    .serialize();
     let source_id = Identifier::new("default", "blob_source");
     let source_schema = blob_schema(&[]);
     fs_catalog
@@ -917,7 +930,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
         blob_batch(
             vec![1, 2],
             vec!["Alice", "Bob"],
-            vec![b"alice".to_vec(), b"bob".to_vec()],
+            vec![first_input, b"bob".to_vec()],
         ),
         "source-writer",
     )
@@ -983,6 +996,8 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
         .await
         .expect("create rest catalog");
 
+    let read_via = 
paimon::api::RESTUtil::encode_string(&serde_json::to_string(&view_id).unwrap());
+    server.require_table_read_via("default", "blob_source", read_via);
     let rest_view = rest_catalog.get_table(&view_id).await.unwrap();
     let predicate = PredicateBuilder::new(rest_view.schema().fields())
         .equal("id", Datum::Int(1))
@@ -1114,7 +1129,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
     assert_eq!(
         collect_blob_rows(&batches),
         vec![
-            (1, "Updated".to_string(), Some(b"alice".to_vec())),
+            (1, "Updated".to_string(), Some(first_payload.clone())),
             (2, "Repeated".to_string(), None),
             (3, "Repeated again".to_string(), Some(b"bob".to_vec())),
         ]
@@ -1140,7 +1155,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
     assert_eq!(
         
collect_blob_rows(&read_all(&authorized.new_read_builder()).await.unwrap()),
         vec![
-            (1, "Updated".to_string(), Some(b"alice".to_vec())),
+            (1, "Updated".to_string(), Some(first_payload.clone())),
             (2, "Repeated".to_string(), None),
             (3, "Repeated again".to_string(), Some(b"bob".to_vec())),
         ]
@@ -1154,7 +1169,7 @@ async fn 
test_blob_view_limit_only_resolves_selected_references() {
     let mut filtered = authorized.new_read_builder();
     filtered.with_projection(&["id"]).unwrap().with_filter(
         PredicateBuilder::new(authorized.schema().fields())
-            .equal("picture", Datum::Bytes(b"alice".to_vec()))
+            .equal("picture", Datum::Bytes(first_payload.clone()))
             .unwrap(),
     );
     let rows: Vec<RecordBatch> = filtered

Reply via email to