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 b262b5f8 [core] Apply primary-key BLOB filters and limits before 
payload resolution (#1053)
b262b5f8 is described below

commit b262b5f8d58ab28e018a14721a5c10a4aa71a951
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 11:10:49 2026 +0800

    [core] Apply primary-key BLOB filters and limits before payload resolution 
(#1053)
---
 .github/workflows/ci.yml                           |   9 +-
 crates/paimon/src/table/data_evolution_reader.rs   |  10 +-
 crates/paimon/src/table/managed_blob_reader.rs     | 694 ++++++++++++++++++---
 .../paimon/src/table/managed_blob_table_tests.rs   | 190 ++++++
 crates/paimon/src/table/mod.rs                     |   1 +
 crates/paimon/src/table/read_limit.rs              |  28 +
 crates/paimon/src/table/table_read.rs              |  97 ++-
 7 files changed, 940 insertions(+), 89 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3138b024..d0cca364 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -118,11 +118,16 @@ jobs:
         if: steps.release_license_inputs.outputs.changed == 'true'
         run: python3 scripts/release_licenses.py --check
 
+      # Keep formatting and deny-warnings lints reproducible across stable
+      # releases. The build matrix independently checks the current toolchain.
+      - name: Install formatting and lint toolchain
+        run: rustup toolchain install 1.98.0 --profile minimal --component 
rustfmt --component clippy
+
       - name: Format
-        run: cargo fmt --all -- --check
+        run: cargo +1.98.0 fmt --all -- --check
 
       - name: Clippy
-        run: cargo clippy --locked --all-targets --workspace --features 
fulltext,vortex -- -D warnings
+        run: cargo +1.98.0 clippy --locked --all-targets --workspace 
--features fulltext,vortex -- -D warnings
 
   msrv:
     name: msrv (${{ matrix.host }})
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 3e6ecb83..69c87940 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -22,6 +22,7 @@ use super::data_file_reader::{
     append_null_row_id_column, attach_row_id, expand_selected_row_ids,
     file_index_selection_to_local_ranges, insert_column_at, 
DataFileReadTiming, DataFileReader,
 };
+use super::read_limit::take_limited_batch;
 use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
 use crate::arrow::format::FilePredicates;
 use crate::arrow::format::MosaicPrefetchOptions;
@@ -141,15 +142,6 @@ pub(crate) struct DataEvolutionReader {
     read_timing: Option<Arc<DataFileReadTiming>>,
 }
 
-fn take_limited_batch(batch: RecordBatch, remaining: &mut Option<usize>) -> 
RecordBatch {
-    let Some(left) = remaining else {
-        return batch;
-    };
-    let taken = batch.num_rows().min(*left);
-    *left -= taken;
-    batch.slice(0, taken)
-}
-
 impl DataEvolutionReader {
     #[allow(clippy::too_many_arguments)]
     pub(crate) fn new(
diff --git a/crates/paimon/src/table/managed_blob_reader.rs 
b/crates/paimon/src/table/managed_blob_reader.rs
index 0d311f95..c4e776b1 100644
--- a/crates/paimon/src/table/managed_blob_reader.rs
+++ b/crates/paimon/src/table/managed_blob_reader.rs
@@ -19,18 +19,20 @@
 
 use super::blob_resolver::{resolve_descriptor_column, BlobReadLimiter};
 use super::managed_blob_writer::{managed_blob_kind, ManagedBlobKind};
+use super::read_limit::take_limited_batch;
 use super::{ArrowRecordBatchStream, Table};
 use crate::arrow::format::FilePredicates;
 use crate::io::FileIO;
-use crate::spec::{CoreOptions, DataField, Predicate};
+use crate::spec::{CoreOptions, DataField, Predicate, PredicateOperator};
 use crate::Result;
-use arrow_array::builder::LargeBinaryBuilder;
 use arrow_array::{
-    Array, ArrayRef, LargeBinaryArray, ListArray, MapArray, RecordBatch, 
StructArray,
+    Array, ArrayRef, BooleanArray, LargeBinaryArray, ListArray, MapArray, 
RecordBatch, StructArray,
+    UInt32Array, UInt64Array,
 };
+use arrow_buffer::{OffsetBuffer, ScalarBuffer};
 use arrow_schema::DataType as ArrowDataType;
 use futures::StreamExt;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 pub(crate) fn resolve_primary_key_blob_stream(
@@ -39,16 +41,19 @@ pub(crate) fn resolve_primary_key_blob_stream(
     options: &CoreOptions<'_>,
     file_io: FileIO,
     parallelism: usize,
+    limit: Option<usize>,
 ) -> ArrowRecordBatchStream {
     let selected = resolved_primary_key_blob_fields(fields, options);
-    if selected.is_empty() {
+    if selected.is_empty() && limit.is_none() {
         return stream;
     }
     let limiter = BlobReadLimiter::with_parallelism(parallelism);
     Box::pin(async_stream::try_stream! {
         let mut stream = stream;
-        while let Some(batch) = stream.next().await {
-            let batch = batch?;
+        let mut remaining = limit;
+        while remaining != Some(0) {
+            let Some(batch) = stream.next().await else { break };
+            let batch = take_limited_batch(batch?, &mut remaining);
             yield resolve_batch(batch, &selected, &file_io, &limiter).await?;
         }
     })
@@ -81,10 +86,21 @@ fn resolved_blob_indices(fields: &[DataField], options: 
&CoreOptions<'_>) -> Has
         .collect()
 }
 
-fn predicate_uses_resolved_blob(predicate: &Predicate, resolved: 
&HashSet<usize>) -> bool {
-    let mut referenced = HashSet::new();
-    predicate.collect_leaf_field_indices(&mut referenced);
-    referenced.iter().any(|index| resolved.contains(index))
+fn predicate_uses_resolved_blob(
+    predicate: &Predicate,
+    resolved: &HashSet<usize>,
+    fields: &[DataField],
+) -> bool {
+    let mut referenced = Vec::new();
+    crate::arrow::residual::collect_predicate_leaf_refs(predicate, &mut 
referenced);
+    referenced.iter().any(|(name, index)| {
+        !crate::spec::is_row_tracking_column(name)
+            && fields
+                .iter()
+                .position(|field| field.name() == *name)
+                .or_else(|| fields.get(*index).map(|_| *index))
+                .is_some_and(|index| resolved.contains(&index))
+    })
 }
 
 /// Drop payload predicates from file and stats pruning while retaining safe
@@ -100,7 +116,9 @@ pub(crate) fn scan_predicates(table: &Table, predicates: 
&[Predicate]) -> Vec<Pr
     }
     predicates
         .iter()
-        .filter(|predicate| !predicate_uses_resolved_blob(predicate, 
&resolved))
+        .filter(|predicate| {
+            !predicate_uses_resolved_blob(predicate, &resolved, 
table.schema().fields())
+        })
         .cloned()
         .collect()
 }
@@ -126,7 +144,7 @@ impl ManagedBlobReadPlan {
         }
         predicates
             .iter()
-            .any(|predicate| predicate_uses_resolved_blob(predicate, 
&resolved))
+            .any(|predicate| predicate_uses_resolved_blob(predicate, 
&resolved, table_fields))
             .then(|| {
                 let predicates = FilePredicates {
                     predicates: predicates.to_vec(),
@@ -153,35 +171,265 @@ impl ManagedBlobReadPlan {
         options: &CoreOptions<'_>,
         file_io: FileIO,
         parallelism: usize,
+        limit: Option<usize>,
     ) -> ArrowRecordBatchStream {
-        let stream = resolve_primary_key_blob_stream(
-            stream,
-            &self.scan_fields,
-            options,
-            file_io,
-            parallelism,
-        );
+        let resolved = resolved_primary_key_blob_fields(&self.scan_fields, 
options);
+        let output_fields = resolved
+            .iter()
+            .copied()
+            .filter(|(index, _)| {
+                self.output_fields
+                    .iter()
+                    .any(|field| field.name() == 
self.scan_fields[*index].name())
+            })
+            .collect::<Vec<_>>();
+        let all_resolved = resolved_blob_indices(&self.predicates.file_fields, 
options);
+        let ordinary_predicates = FilePredicates {
+            predicates: self
+                .predicates
+                .predicates
+                .iter()
+                .filter(|predicate| {
+                    !predicate_uses_resolved_blob(
+                        predicate,
+                        &all_resolved,
+                        &self.predicates.file_fields,
+                    )
+                })
+                .cloned()
+                .collect(),
+            row_filter_factory: None,
+            file_fields: self.predicates.file_fields.clone(),
+        };
+        let limiter = BlobReadLimiter::with_parallelism(parallelism);
         Box::pin(async_stream::try_stream! {
             let mut stream = stream;
-            while let Some(batch) = stream.next().await {
+            let mut remaining = limit;
+            'batches: while remaining != Some(0) {
+                let Some(batch) = stream.next().await else { break };
                 let batch = 
crate::arrow::residual::filter_record_batch_by_predicates(
-                    batch?, &self.predicates, &self.scan_fields,
+                    batch?, &ordinary_predicates, &self.scan_fields,
                 )?;
-                let indices = self.output_fields
-                    .iter()
-                    .map(|field| batch.schema().index_of(field.name()))
-                    .collect::<std::result::Result<Vec<_>, _>>()
-                    .map_err(|error| crate::Error::DataInvalid {
-                        message: format!("Managed BLOB output projection is 
missing a column: {error}"),
+                // Inspect at most the remaining quota of candidate rows. Even
+                // if every candidate matches, none is beyond LIMIT. Rejected
+                // rows consume no quota; shrink the next batch accordingly.
+                let mut offset = 0;
+                while offset < batch.num_rows() {
+                    if remaining == Some(0) { break 'batches; }
+                    let width = 
remaining.unwrap_or(batch.num_rows()).min(batch.num_rows() - offset);
+                    let candidate = batch.slice(offset, width);
+                    offset += width;
+                    let mut filter = BlobPredicateBatch::new(
+                        candidate, &self.predicates, &self.scan_fields,
+                        &resolved, &file_io, &limiter,
+                    );
+                    let mut selected = vec![true; filter.batch.num_rows()];
+                    for predicate in &self.predicates.predicates {
+                        selected = filter.evaluate(predicate, selected).await?;
+                    }
+                    // Cache predicate payloads, and fetch output-only payloads
+                    // solely for rows that survived Boolean short-circuiting.
+                    filter.resolve_fields(&output_fields, &selected).await?;
+                    let candidate = arrow_select::filter::filter_record_batch(
+                        &filter.batch, &BooleanArray::from(selected),
+                    ).map_err(|error| invalid(&error.to_string()))?;
+                    let candidate = take_limited_batch(candidate, &mut 
remaining);
+                    if candidate.num_rows() == 0 { continue; }
+                    let batch = candidate;
+                    let indices = self.output_fields
+                        .iter()
+                        .map(|field| batch.schema().index_of(field.name()))
+                        .collect::<std::result::Result<Vec<_>, _>>()
+                        .map_err(|error| crate::Error::DataInvalid {
+                            message: format!("Managed BLOB output projection 
is missing a column: {error}"),
+                            source: Some(Box::new(error)),
+                        })?;
+                    yield batch.project(&indices).map_err(|error| 
crate::Error::DataInvalid {
+                        message: format!("Failed to project managed BLOB read 
output: {error}"),
                         source: Some(Box::new(error)),
                     })?;
-                yield batch.project(&indices).map_err(|error| 
crate::Error::DataInvalid {
-                    message: format!("Failed to project managed BLOB read 
output: {error}"),
-                    source: Some(Box::new(error)),
-                })?;
+                }
+            }
+        })
+    }
+}
+
+/// Java's descriptor rows resolve a payload only when a predicate accesses it.
+/// Apply the same AND/OR short-circuiting to batches, caching resolved rows so
+/// repeated leaves and output projection do not fetch a payload twice.
+struct BlobPredicateBatch<'a> {
+    batch: RecordBatch,
+    predicates: &'a FilePredicates,
+    scan_fields: &'a [DataField],
+    blob_fields: &'a [(usize, ManagedBlobKind)],
+    resolved: HashMap<usize, Vec<bool>>,
+    file_io: &'a FileIO,
+    limiter: &'a BlobReadLimiter,
+}
+
+impl<'a> BlobPredicateBatch<'a> {
+    fn new(
+        batch: RecordBatch,
+        predicates: &'a FilePredicates,
+        scan_fields: &'a [DataField],
+        blob_fields: &'a [(usize, ManagedBlobKind)],
+        file_io: &'a FileIO,
+        limiter: &'a BlobReadLimiter,
+    ) -> Self {
+        Self {
+            batch,
+            predicates,
+            scan_fields,
+            blob_fields,
+            resolved: HashMap::new(),
+            file_io,
+            limiter,
+        }
+    }
+
+    fn evaluate<'b>(
+        &'b mut self,
+        predicate: &'b Predicate,
+        active: Vec<bool>,
+    ) -> futures::future::BoxFuture<'b, Result<Vec<bool>>> {
+        Box::pin(async move {
+            if !active.iter().any(|selected| *selected) {
+                return Ok(active);
+            }
+            match predicate {
+                Predicate::AlwaysTrue => Ok(active),
+                Predicate::AlwaysFalse => Ok(vec![false; active.len()]),
+                Predicate::And(children) => {
+                    let mut selected = active;
+                    for child in children {
+                        selected = self.evaluate(child, selected).await?;
+                    }
+                    Ok(selected)
+                }
+                Predicate::Or(children) => {
+                    let mut pending = active;
+                    let mut selected = vec![false; pending.len()];
+                    for child in children {
+                        let matched = self.evaluate(child, 
pending.clone()).await?;
+                        for row in 0..pending.len() {
+                            selected[row] |= matched[row];
+                            pending[row] &= !matched[row];
+                        }
+                    }
+                    Ok(selected)
+                }
+                Predicate::Not(child) => {
+                    let matched = self.evaluate(child, active.clone()).await?;
+                    Ok(active
+                        .iter()
+                        .zip(matched)
+                        .map(|(active, matched)| *active && !matched)
+                        .collect())
+                }
+                Predicate::Leaf { op, .. } => {
+                    let fields = self
+                        .blob_fields
+                        .iter()
+                        .copied()
+                        .filter(|(index, _)| {
+                            predicate_uses_resolved_blob(
+                                predicate,
+                                &HashSet::from([*index]),
+                                self.scan_fields,
+                            )
+                        })
+                        .collect::<Vec<_>>();
+                    // Java null checks inspect the BLOB reference's validity,
+                    // without opening its payload. Resolution preserves nulls.
+                    if !matches!(op, PredicateOperator::IsNull | 
PredicateOperator::IsNotNull) {
+                        self.resolve_fields(&fields, &active).await?;
+                    }
+                    let mask = 
crate::arrow::residual::evaluate_predicates_mask(
+                        &self.batch,
+                        std::slice::from_ref(predicate),
+                        &self.predicates.file_fields,
+                        self.scan_fields,
+                    )?;
+                    Ok(active
+                        .iter()
+                        .enumerate()
+                        .map(|(row, active)| {
+                            *active && mask.as_ref().is_none_or(|mask| 
mask.value(row))
+                        })
+                        .collect())
+                }
             }
         })
     }
+
+    async fn resolve_fields(
+        &mut self,
+        fields: &[(usize, ManagedBlobKind)],
+        active: &[bool],
+    ) -> Result<()> {
+        for &(index, kind) in fields {
+            let resolved = self
+                .resolved
+                .entry(index)
+                .or_insert_with(|| vec![false; active.len()]);
+            let pending = active
+                .iter()
+                .zip(resolved.iter())
+                .map(|(active, resolved)| *active && !*resolved)
+                .collect::<Vec<_>>();
+            let count = pending.iter().filter(|pending| **pending).count();
+            if count == 0 {
+                continue;
+            }
+            if count == self.batch.num_rows() {
+                self.batch = resolve_batch(
+                    self.batch.clone(),
+                    &[(index, kind)],
+                    self.file_io,
+                    self.limiter,
+                )
+                .await?;
+            } else {
+                let selected = arrow_select::filter::filter_record_batch(
+                    &self.batch,
+                    &BooleanArray::from(pending.clone()),
+                )
+                .map_err(|error| invalid(&error.to_string()))?;
+                let selected =
+                    resolve_batch(selected, &[(index, kind)], self.file_io, 
self.limiter).await?;
+                let values = arrow_select::concat::concat(&[
+                    self.batch.column(index).as_ref(),
+                    selected.column(index).as_ref(),
+                ])
+                .map_err(|error| invalid(&error.to_string()))?;
+                let mut next = self.batch.num_rows() as u64;
+                let indices = UInt64Array::from(
+                    pending
+                        .iter()
+                        .enumerate()
+                        .map(|(row, selected)| {
+                            if *selected {
+                                let index = next;
+                                next += 1;
+                                index
+                            } else {
+                                row as u64
+                            }
+                        })
+                        .collect::<Vec<_>>(),
+                );
+                let mut columns = self.batch.columns().to_vec();
+                columns[index] = arrow_select::take::take(values.as_ref(), 
&indices, None)
+                    .map_err(|error| invalid(&error.to_string()))?;
+                self.batch = RecordBatch::try_new(self.batch.schema(), columns)
+                    .map_err(|error| invalid(&error.to_string()))?;
+            }
+            for (resolved, pending) in resolved.iter_mut().zip(pending) {
+                *resolved |= pending;
+            }
+        }
+        Ok(())
+    }
 }
 
 async fn resolve_batch(
@@ -190,6 +438,9 @@ async fn resolve_batch(
     file_io: &FileIO,
     limiter: &BlobReadLimiter,
 ) -> Result<RecordBatch> {
+    if fields.is_empty() || batch.num_rows() == 0 {
+        return Ok(batch);
+    }
     let mut columns = batch.columns().to_vec();
     for &(index, kind) in fields {
         let column = match kind {
@@ -204,16 +455,17 @@ async fn resolve_batch(
                     .downcast_ref::<ListArray>()
                     .ok_or_else(|| invalid("ARRAY<BLOB> requires ListArray"))?;
                 let values = blob_values(array.values().as_ref())?;
-                let visible = visible_child_values(values, 
array.value_offsets(), array)?;
-                let resolved =
-                    Arc::new(resolve_descriptor_column(&visible, file_io, 
limiter.clone()).await?);
+                let visible = visible_child_values(values, array.offsets(), 
array)?;
+                let resolved = Arc::new(
+                    resolve_descriptor_column(&visible.values, file_io, 
limiter.clone()).await?,
+                );
                 let ArrowDataType::List(element) = array.data_type() else {
                     unreachable!()
                 };
                 Arc::new(
                     ListArray::try_new(
                         element.clone(),
-                        array.offsets().clone(),
+                        visible.offsets,
                         resolved,
                         array.nulls().cloned(),
                     )
@@ -226,25 +478,30 @@ async fn resolve_batch(
                     .downcast_ref::<MapArray>()
                     .ok_or_else(|| invalid("MAP<X, BLOB> requires MapArray"))?;
                 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_descriptor_column(&visible, file_io, 
limiter.clone()).await?);
+                let visible = visible_child_values(values, map.offsets(), 
map)?;
+                let resolved = Arc::new(
+                    resolve_descriptor_column(&visible.values, file_io, 
limiter.clone()).await?,
+                );
                 let ArrowDataType::Map(entries_field, ordered) = 
map.data_type() else {
                     unreachable!()
                 };
                 let ArrowDataType::Struct(entry_fields) = 
entries_field.data_type() else {
                     unreachable!()
                 };
-                let entries = StructArray::try_new(
-                    entry_fields.clone(),
-                    vec![map.entries().column(0).clone(), resolved],
-                    None,
-                )
-                .map_err(|error| invalid(&error.to_string()))?;
+                let keys = match visible.indices {
+                    Some(indices) => {
+                        
arrow_select::take::take(map.entries().column(0).as_ref(), &indices, None)
+                            .map_err(|error| invalid(&error.to_string()))?
+                    }
+                    None => map.entries().column(0).clone(),
+                };
+                let entries =
+                    StructArray::try_new(entry_fields.clone(), vec![keys, 
resolved], None)
+                        .map_err(|error| invalid(&error.to_string()))?;
                 Arc::new(
                     MapArray::try_new(
                         entries_field.clone(),
-                        map.offsets().clone(),
+                        visible.offsets,
                         entries,
                         map.nulls().cloned(),
                         *ordered,
@@ -258,40 +515,59 @@ async fn resolve_batch(
     RecordBatch::try_new(batch.schema(), columns).map_err(|error| 
invalid(&error.to_string()))
 }
 
-/// Arrow may retain child values beneath a null collection parent. They are
-/// invisible to the row and must not trigger descriptor I/O or fail the read
-/// when a stale URI happens to remain in an unused child slot.
+struct VisibleBlobChildren {
+    values: LargeBinaryArray,
+    offsets: OffsetBuffer<i32>,
+    indices: Option<UInt32Array>,
+}
+
+/// Drop children outside sliced parents or beneath NULL parents. Rebase the
+/// offsets and select the same map keys. Filling hidden values with NULL would
+/// violate a non-nullable element/value type, despite those slots being 
unused.
 fn visible_child_values(
     values: &LargeBinaryArray,
-    offsets: &[i32],
+    offsets: &OffsetBuffer<i32>,
     parents: &dyn Array,
-) -> Result<LargeBinaryArray> {
+) -> Result<VisibleBlobChildren> {
     if offsets.len() != parents.len() + 1 {
         return Err(invalid("BLOB collection offsets do not match parent 
rows"));
     }
-    let mut visible = vec![false; values.len()];
-    for row in 0..parents.len() {
-        if !parents.is_valid(row) {
-            continue;
-        }
-        let start = usize::try_from(offsets[row])
-            .map_err(|_| invalid("Negative BLOB collection offset"))?;
-        let end = usize::try_from(offsets[row + 1])
-            .map_err(|_| invalid("Negative BLOB collection offset"))?;
-        let range = visible
-            .get_mut(start..end)
-            .ok_or_else(|| invalid("BLOB collection offset exceeds child 
values"))?;
-        range.fill(true);
+    if parents.null_count() == 0
+        && offsets[0] == 0
+        && offsets[parents.len()] as usize == values.len()
+    {
+        return Ok(VisibleBlobChildren {
+            values: values.clone(),
+            offsets: offsets.clone(),
+            indices: None,
+        });
     }
-    let mut builder = LargeBinaryBuilder::new();
-    for (index, visible) in visible.into_iter().enumerate() {
-        if visible && values.is_valid(index) {
-            builder.append_value(values.value(index));
-        } else {
-            builder.append_null();
+    let mut indices = Vec::new();
+    let mut output_offsets = vec![0_i32];
+    for row in 0..parents.len() {
+        if parents.is_valid(row) {
+            let start = usize::try_from(offsets[row])
+                .map_err(|_| invalid("Negative BLOB collection offset"))?;
+            let end = usize::try_from(offsets[row + 1])
+                .map_err(|_| invalid("Negative BLOB collection offset"))?;
+            if start > end || end > values.len() {
+                return Err(invalid("BLOB collection offset exceeds child 
values"));
+            }
+            indices.extend((start..end).map(|index| index as u32));
         }
+        output_offsets.push(
+            i32::try_from(indices.len())
+                .map_err(|_| invalid("BLOB collection exceeds i32 offsets"))?,
+        );
     }
-    Ok(builder.finish())
+    let indices = UInt32Array::from(indices);
+    let selected = arrow_select::take::take(values, &indices, None)
+        .map_err(|error| invalid(&error.to_string()))?;
+    Ok(VisibleBlobChildren {
+        values: blob_values(selected.as_ref())?.clone(),
+        offsets: OffsetBuffer::new(ScalarBuffer::from(output_offsets)),
+        indices: Some(indices),
+    })
 }
 
 fn blob_values(array: &dyn Array) -> Result<&LargeBinaryArray> {
@@ -317,6 +593,278 @@ mod tests {
     use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
     use arrow_schema::{Field, Schema};
 
+    #[tokio::test]
+    async fn payload_filter_short_circuits_boolean_branches_and_null_checks() {
+        use crate::spec::{BlobType, DataType, Datum, IntType, 
PredicateBuilder};
+        use futures::TryStreamExt;
+
+        let io = FileIOBuilder::new("memory").build().unwrap();
+        io.new_output("memory:/predicate-selected")
+            .unwrap()
+            .write(bytes::Bytes::from_static(b"yes"))
+            .await
+            .unwrap();
+        let good = BlobDescriptor::new("memory:/predicate-selected".into(), 0, 
3).serialize();
+        let missing = BlobDescriptor::new("memory:/must-not-open".into(), 0, 
3).serialize();
+        let fields = vec![
+            DataField::new(0, "id".into(), DataType::Int(IntType::new())),
+            DataField::new(1, "payload".into(), 
DataType::Blob(BlobType::new())),
+        ];
+        let batch = RecordBatch::try_new(
+            crate::arrow::build_target_arrow_schema(&fields).unwrap(),
+            vec![
+                Arc::new(arrow_array::Int32Array::from(vec![1, 2, 3])),
+                Arc::new(LargeBinaryArray::from(vec![
+                    Some(missing.as_slice()),
+                    Some(good.as_slice()),
+                    None,
+                ])),
+            ],
+        )
+        .unwrap();
+        let builder = PredicateBuilder::new(&fields);
+        let equal_payload = builder
+            .equal("payload", Datum::Bytes(b"yes".to_vec()))
+            .unwrap();
+        let id_one = builder.equal("id", Datum::Int(1)).unwrap();
+        let cases = [
+            (
+                Predicate::Or(vec![id_one.clone(), equal_payload.clone()]),
+                vec![1, 2],
+            ),
+            (
+                Predicate::Or(vec![
+                    Predicate::And(vec![
+                        id_one.clone(),
+                        builder.is_not_null("payload").unwrap(),
+                    ]),
+                    Predicate::And(vec![equal_payload.clone(), 
equal_payload.clone()]),
+                ]),
+                vec![1, 2],
+            ),
+            (
+                Predicate::And(vec![
+                    builder.is_not_null("payload").unwrap(),
+                    Predicate::Not(Box::new(Predicate::Or(vec![
+                        id_one,
+                        builder
+                            .not_equal("payload", 
Datum::Bytes(b"yes".to_vec()))
+                            .unwrap(),
+                    ]))),
+                ]),
+                vec![2],
+            ),
+            (builder.is_null("payload").unwrap(), vec![3]),
+            (builder.is_not_null("payload").unwrap(), vec![1, 2]),
+        ];
+        let options = std::collections::HashMap::new();
+        let options = CoreOptions::new(&options);
+        for (predicate, expected) in cases {
+            for limit in [None, Some(1), Some(2)] {
+                let plan = ManagedBlobReadPlan::new(
+                    &fields[..1],
+                    std::slice::from_ref(&predicate),
+                    &fields,
+                    &options,
+                )
+                .unwrap();
+                let stream = 
Box::pin(futures::stream::iter(vec![Ok(batch.clone())]));
+                let batches: Vec<RecordBatch> = plan
+                    .finish(stream, &options, io.clone(), 2, limit)
+                    .try_collect()
+                    .await
+                    .unwrap();
+                let actual: Vec<i32> = batches
+                    .iter()
+                    .flat_map(|batch| {
+                        batch
+                            .column(0)
+                            .as_any()
+                            .downcast_ref::<arrow_array::Int32Array>()
+                            .unwrap()
+                            .values()
+                            .to_vec()
+                    })
+                    .collect();
+                assert_eq!(
+                    actual,
+                    
expected[..limit.unwrap_or(expected.len()).min(expected.len())]
+                );
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn 
predicate_and_output_share_payload_bytes_resembling_a_descriptor() {
+        use crate::spec::{BlobType, DataType, Datum, PredicateBuilder};
+        use futures::TryStreamExt;
+
+        let io = FileIOBuilder::new("memory").build().unwrap();
+        let payload =
+            BlobDescriptor::new("memory:/returned-bytes-must-not-open".into(), 
0, 3).serialize();
+        io.new_output("memory:/predicate-payload")
+            .unwrap()
+            .write(bytes::Bytes::from(payload.clone()))
+            .await
+            .unwrap();
+        let descriptor =
+            BlobDescriptor::new("memory:/predicate-payload".into(), 0, 
payload.len() as i64)
+                .serialize();
+        let fields = vec![DataField::new(
+            0,
+            "payload".into(),
+            DataType::Blob(BlobType::new()),
+        )];
+        let batch = RecordBatch::try_new(
+            crate::arrow::build_target_arrow_schema(&fields).unwrap(),
+            vec![Arc::new(LargeBinaryArray::from(vec![Some(
+                descriptor.as_slice(),
+            )]))],
+        )
+        .unwrap();
+        let predicate = PredicateBuilder::new(&fields)
+            .equal("payload", Datum::Bytes(payload.clone()))
+            .unwrap();
+        let options = std::collections::HashMap::new();
+        let options = CoreOptions::new(&options);
+        let plan = ManagedBlobReadPlan::new(
+            &fields,
+            &[Predicate::And(vec![predicate.clone(), predicate])],
+            &fields,
+            &options,
+        )
+        .unwrap();
+        let stream = Box::pin(futures::stream::iter(vec![Ok(batch)]));
+        let batches: Vec<RecordBatch> = plan
+            .finish(stream, &options, io, 2, None)
+            .try_collect()
+            .await
+            .unwrap();
+        assert_eq!(batches.len(), 1);
+        assert_eq!(
+            batches[0]
+                .column(0)
+                .as_any()
+                .downcast_ref::<LargeBinaryArray>()
+                .unwrap()
+                .value(0),
+            payload
+        );
+    }
+
+    #[tokio::test]
+    async fn 
collection_limit_skips_unselected_children_with_both_nullabilities() {
+        use crate::spec::{ArrayType, BlobType, DataType, MapType, VarCharType};
+        use futures::TryStreamExt;
+        for nullable in [true, false] {
+            let io = FileIOBuilder::new("memory").build().unwrap();
+            io.new_output("memory:/selected")
+                .unwrap()
+                .write(bytes::Bytes::from_static(b"selected"))
+                .await
+                .unwrap();
+            let selected = BlobDescriptor::new("memory:/selected".into(), 0, 
8).serialize();
+            let missing = BlobDescriptor::new("memory:/missing".into(), 0, 
8).serialize();
+            let fields = vec![
+                DataField::new(
+                    0,
+                    "items".into(),
+                    
DataType::Array(ArrayType::new(DataType::Blob(BlobType::with_nullable(
+                        nullable,
+                    )))),
+                ),
+                DataField::new(
+                    1,
+                    "named".into(),
+                    DataType::Map(MapType::new(
+                        DataType::VarChar(VarCharType::string_type()),
+                        DataType::Blob(BlobType::with_nullable(nullable)),
+                    )),
+                ),
+            ];
+            let schema = 
crate::arrow::build_target_arrow_schema(&fields).unwrap();
+            let values: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+                Some(selected.as_slice()),
+                Some(missing.as_slice()),
+            ]));
+            let ArrowDataType::List(element) = schema.field(0).data_type() 
else {
+                panic!("expected list")
+            };
+            let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, 1, 2]));
+            let list: ArrayRef = Arc::new(
+                ListArray::try_new(element.clone(), offsets.clone(), 
values.clone(), None).unwrap(),
+            );
+            let ArrowDataType::Map(entries_field, ordered) = 
schema.field(1).data_type() else {
+                panic!("expected map")
+            };
+            let ArrowDataType::Struct(entry_fields) = 
entries_field.data_type() else {
+                panic!("expected entries")
+            };
+            let entries = StructArray::try_new(
+                entry_fields.clone(),
+                vec![Arc::new(StringArray::from(vec!["keep", "skip"])), 
values],
+                None,
+            )
+            .unwrap();
+            let map: ArrayRef = Arc::new(
+                MapArray::try_new(entries_field.clone(), offsets, entries, 
None, *ordered).unwrap(),
+            );
+            let batch = RecordBatch::try_new(schema, vec![list, map]).unwrap();
+            let stream = Box::pin(futures::stream::iter(vec![Ok(batch)]));
+            let batches: Vec<_> = resolve_primary_key_blob_stream(
+                stream,
+                &fields,
+                &CoreOptions::new(&std::collections::HashMap::new()),
+                io,
+                2,
+                Some(1),
+            )
+            .try_collect()
+            .await
+            .unwrap();
+            assert_eq!(batches.len(), 1);
+            assert_eq!(batches[0].num_rows(), 1);
+            let list = batches[0]
+                .column(0)
+                .as_any()
+                .downcast_ref::<ListArray>()
+                .unwrap();
+            let items = list.value(0);
+            assert_eq!(
+                items
+                    .as_any()
+                    .downcast_ref::<LargeBinaryArray>()
+                    .unwrap()
+                    .value(0),
+                b"selected"
+            );
+            let map = batches[0]
+                .column(1)
+                .as_any()
+                .downcast_ref::<MapArray>()
+                .unwrap();
+            let entries = map.value(0);
+            assert_eq!(
+                entries
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .unwrap()
+                    .value(0),
+                "keep"
+            );
+            assert_eq!(
+                entries
+                    .column(1)
+                    .as_any()
+                    .downcast_ref::<LargeBinaryArray>()
+                    .unwrap()
+                    .value(0),
+                b"selected"
+            );
+        }
+    }
+
     #[tokio::test]
     async fn null_array_parent_does_not_fetch_hidden_descriptor() {
         let missing =
@@ -353,7 +901,7 @@ mod tests {
             .downcast_ref::<ListArray>()
             .unwrap();
         assert!(array.is_null(0));
-        assert!(array.values().is_null(0));
+        assert_eq!(array.values().len(), 0);
     }
 
     #[tokio::test]
@@ -409,6 +957,6 @@ mod tests {
             .downcast_ref::<MapArray>()
             .unwrap();
         assert!(map.is_null(0));
-        assert!(map.entries().column(1).is_null(0));
+        assert_eq!(map.entries().len(), 0);
     }
 }
diff --git a/crates/paimon/src/table/managed_blob_table_tests.rs 
b/crates/paimon/src/table/managed_blob_table_tests.rs
index e9d7cd92..85de4a08 100644
--- a/crates/paimon/src/table/managed_blob_table_tests.rs
+++ b/crates/paimon/src/table/managed_blob_table_tests.rs
@@ -1150,6 +1150,7 @@ async fn read_pending_values(table: &Table, file: 
&crate::spec::DataFileMeta) ->
         &table.schema().core_options(),
         table.file_io().clone(),
         2,
+        None,
     )
     .try_collect()
     .await
@@ -1321,6 +1322,195 @@ async fn 
primary_key_blob_payload_limit_keeps_later_buckets() {
     }
 }
 
+#[tokio::test]
+async fn primary_key_blob_limit_never_fetches_later_descriptors() {
+    for payload_filter in [false, true] {
+        for batch_size in [1, 8192] {
+            let io = test_file_io();
+            let path = 
format!("memory:/pk_blob_limit_{payload_filter}_{batch_size}");
+            setup_dirs(&io, &path).await;
+            let table = scalar_table(
+                &io,
+                &path,
+                &[
+                    ("blob-descriptor-field", "payload"),
+                    ("read.batch-size", &batch_size.to_string()),
+                ],
+            );
+            io.new_output("memory:/limit-source")
+                .unwrap()
+                .write(bytes::Bytes::from_static(b"selected"))
+                .await
+                .unwrap();
+            let selected = BlobDescriptor::new("memory:/limit-source".into(), 
0, 8).serialize();
+            let missing = BlobDescriptor::new("memory:/not-selected".into(), 
0, 8).serialize();
+            let mut writer = TableWrite::new(&table, 
"limit-test".into()).unwrap();
+            writer
+                .write_arrow_batch(&scalar_batch(&[(1, Some(&selected)), (2, 
Some(&missing))]))
+                .await
+                .unwrap();
+            let messages = writer.prepare_commit().await.unwrap();
+            TableCommit::new(table.clone(), "limit-test".into())
+                .commit(messages)
+                .await
+                .unwrap();
+            let mut builder = table.new_read_builder();
+            builder
+                .with_projection(&["id", "payload"])
+                .unwrap()
+                .with_limit(1);
+            if payload_filter {
+                builder.with_filter(
+                    PredicateBuilder::new(table.schema().fields())
+                        .equal("payload", Datum::Bytes(b"selected".to_vec()))
+                        .unwrap(),
+                );
+            }
+            let plan = builder.new_scan().plan().await.unwrap();
+            let batches: Vec<RecordBatch> = builder
+                .new_read()
+                .unwrap()
+                .to_arrow(plan.splits())
+                .unwrap()
+                .try_collect()
+                .await
+                .unwrap();
+            
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+            let row = batches.iter().find(|batch| batch.num_rows() != 
0).unwrap();
+            assert_eq!(
+                row.column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .value(0),
+                1
+            );
+            assert_eq!(
+                row.column(1)
+                    .as_any()
+                    .downcast_ref::<LargeBinaryArray>()
+                    .unwrap()
+                    .value(0),
+                b"selected"
+            );
+            let streaming: Vec<_> = plan
+                .splits()
+                .iter()
+                .map(|split| {
+                    super::DataSplitBuilder::new()
+                        .with_snapshot(split.snapshot_id())
+                        .with_partition(split.partition().clone())
+                        .with_bucket(split.bucket())
+                        .with_bucket_path(split.bucket_path().to_string())
+                        .with_total_buckets(split.total_buckets())
+                        .with_data_files(split.data_files().to_vec())
+                        .with_streaming(true)
+                        .build()
+                        .unwrap()
+                })
+                .collect();
+            for mixed in [false, true] {
+                let mut splits = streaming.clone();
+                if mixed {
+                    splits.extend_from_slice(plan.splits());
+                }
+                let batches: Vec<RecordBatch> = builder
+                    .new_read()
+                    .unwrap()
+                    .to_arrow_with_row_kind(&splits)
+                    .unwrap()
+                    .try_collect()
+                    .await
+                    .unwrap();
+                
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+                let row = batches.iter().find(|batch| batch.num_rows() != 
0).unwrap();
+                assert_eq!(
+                    row.column_by_name("payload")
+                        .unwrap()
+                        .as_any()
+                        .downcast_ref::<LargeBinaryArray>()
+                        .unwrap()
+                        .value(0),
+                    b"selected"
+                );
+                assert_eq!(
+                    row.column_by_name(crate::spec::ROW_KIND_FIELD_NAME)
+                        .unwrap()
+                        .as_any()
+                        .downcast_ref::<StringArray>()
+                        .unwrap()
+                        .value(0),
+                    "+I"
+                );
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn primary_key_blob_sequence_predicate_does_not_alias_payload_index() {
+    let io = test_file_io();
+    let path = "memory:/pk_blob_sequence_filter";
+    setup_dirs(&io, path).await;
+    let table = scalar_table(&io, path, &[("blob-descriptor-field", 
"payload")]);
+    io.new_output("memory:/sequence-selected")
+        .unwrap()
+        .write(bytes::Bytes::from_static(b"selected"))
+        .await
+        .unwrap();
+    let selected = BlobDescriptor::new("memory:/sequence-selected".into(), 0, 
8).serialize();
+    let missing = BlobDescriptor::new("memory:/sequence-unselected".into(), 0, 
8).serialize();
+    let mut writer = TableWrite::new(&table, "sequence-test".into()).unwrap();
+    writer
+        .write_arrow_batch(&scalar_batch(&[(1, Some(&selected)), (2, 
Some(&missing))]))
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "sequence-test".into())
+        .commit(writer.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    // Metadata indices belong to the caller's read schema. Index 1 here
+    // coincides with payload in the logical table schema; the name is 
decisive.
+    let predicate_fields = [
+        table.schema().fields()[0].clone(),
+        DataField::new(
+            SEQUENCE_NUMBER_FIELD_ID,
+            SEQUENCE_NUMBER_FIELD_NAME.into(),
+            DataType::BigInt(BigIntType::new()),
+        ),
+    ];
+    let predicate = PredicateBuilder::new(&predicate_fields)
+        .equal(SEQUENCE_NUMBER_FIELD_NAME, Datum::Long(0))
+        .unwrap();
+    let mut builder = table.new_read_builder();
+    builder
+        .with_projection(&["payload"])
+        .unwrap()
+        .with_filter(predicate);
+    let plan = builder.new_scan().plan().await.unwrap();
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+    assert_eq!(
+        batches
+            .iter()
+            .find(|batch| batch.num_rows() != 0)
+            .unwrap()
+            .column(0)
+            .as_any()
+            .downcast_ref::<LargeBinaryArray>()
+            .unwrap()
+            .value(0),
+        b"selected"
+    );
+}
+
 #[tokio::test]
 async fn postpone_blob_failed_checkpoint_preserves_prepared_packs() {
     let file_io = test_file_io();
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 58134550..09240586 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -119,6 +119,7 @@ mod postpone_retract;
 mod prepared_files;
 mod query_auth;
 mod read_builder;
+mod read_limit;
 pub mod referenced_files;
 pub(crate) mod rest_env;
 pub(crate) mod row_id_predicate;
diff --git a/crates/paimon/src/table/read_limit.rs 
b/crates/paimon/src/table/read_limit.rs
new file mode 100644
index 00000000..39823009
--- /dev/null
+++ b/crates/paimon/src/table/read_limit.rs
@@ -0,0 +1,28 @@
+// 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.
+
+use arrow_array::RecordBatch;
+
+/// Consume the quota after merging and exact filtering. Slicing before output
+/// BLOB resolution matches Java's lazy BlobDescriptorResolvingRow and
+/// LimitRecordReader: discarded payloads remain untouched.
+pub(super) fn take_limited_batch(batch: RecordBatch, remaining: &mut 
Option<usize>) -> RecordBatch {
+    let Some(left) = remaining else { return batch };
+    let taken = batch.num_rows().min(*left);
+    *left -= taken;
+    batch.slice(0, taken)
+}
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index ee0d3a0f..0d45ea63 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -172,9 +172,8 @@ impl<'a> TableRead<'a> {
         })
     }
 
-    /// Pass the read limit to paths that can enforce it. Data-evolution reads
-    /// apply it before BLOB resolution; other Paimon reads still use the
-    /// builder limit only as a scan hint.
+    /// Pass the limit to Data Evolution and primary-key readers. These paths
+    /// apply it after merging/filtering and before output BLOB resolution.
     pub(crate) fn with_limit(self, limit: Option<usize>) -> Self {
         match self.0 {
             TableReadKind::Paimon(mut read) => {
@@ -514,6 +513,9 @@ impl<'a> PaimonTableRead<'a> {
             .iter()
             .cloned()
             .partition(DataSplit::is_streaming);
+        if self.limit.is_some() && !streaming.is_empty() && 
!materialized.is_empty() {
+            return self.limited_mixed_row_kind_stream(materialized, streaming);
+        }
         let mut streams = Vec::with_capacity(2);
         if !materialized.is_empty() {
             streams.push(prepend_insert_row_kind_stream(
@@ -534,6 +536,47 @@ impl<'a> PaimonTableRead<'a> {
         Ok(Box::pin(stream::select_all(streams)))
     }
 
+    /// Open each kind of split only after the preceding group consumed its
+    /// quota. Truncating two independently limited streams afterward could
+    /// fetch BLOB payloads from a group whose rows will never be returned.
+    fn limited_mixed_row_kind_stream(
+        &self,
+        materialized: Vec<DataSplit>,
+        streaming: Vec<DataSplit>,
+    ) -> crate::Result<ArrowRecordBatchStream> {
+        let table = self.table.clone();
+        let read_type = self.read_type.clone();
+        let data_predicates = self.data_predicates.clone();
+        let row_filter_factory = self.row_filter_factory.clone();
+        let parquet_read_budget = self.parquet_read_budget()?;
+        let resources = self.resources.clone();
+        let data_file_read_timing = self.data_file_read_timing.clone();
+        let blob_parallelism = self.blob_parallelism;
+        let limit = self.limit;
+        Ok(Box::pin(async_stream::try_stream! {
+            let mut remaining = limit;
+            for group in [materialized, streaming] {
+                if remaining == Some(0) { break; }
+                let read = PaimonTableRead {
+                    table: &table,
+                    read_type: read_type.clone(),
+                    data_predicates: data_predicates.clone(),
+                    row_filter_factory: row_filter_factory.clone(),
+                    parquet_read_budget: Some(parquet_read_budget.clone()),
+                    resources: resources.clone(),
+                    data_file_read_timing: data_file_read_timing.clone(),
+                    blob_parallelism,
+                    limit: remaining,
+                };
+                let mut stream = read.to_arrow_with_row_kind(&group)?;
+                while remaining != Some(0) {
+                    let Some(batch) = stream.next().await else { break };
+                    yield super::read_limit::take_limited_batch(batch?, &mut 
remaining);
+                }
+            }
+        }))
+    }
+
     fn audit_raw_stream(
         &self,
         plan: &IncrementalPlan,
@@ -554,6 +597,8 @@ impl<'a> PaimonTableRead<'a> {
         has_value_kind: bool,
         include_sequence: bool,
     ) -> crate::Result<ArrowRecordBatchStream> {
+        use super::managed_blob_reader::{resolve_primary_key_blob_stream, 
ManagedBlobReadPlan};
+
         let core_options = self.table.schema().core_options();
         let user_read_type = self.read_type.clone();
         let audit_schema = audit_schema_for_read_type(&user_read_type, 
include_sequence)?;
@@ -577,14 +622,34 @@ impl<'a> PaimonTableRead<'a> {
             ));
         }
 
+        let primary_key = !self.table.schema().primary_keys().is_empty();
+        let blob_plan = primary_key
+            .then(|| {
+                ManagedBlobReadPlan::new(
+                    &read_type,
+                    &self.data_predicates,
+                    self.table.schema().fields(),
+                    &core_options,
+                )
+            })
+            .flatten();
+        let scan_type = blob_plan
+            .as_ref()
+            .map_or_else(|| read_type.clone(), |plan| 
plan.scan_fields().to_vec());
+        let predicates = if blob_plan.is_some() {
+            Vec::new()
+        } else {
+            self.data_predicates.clone()
+        };
         let reader = DataFileReader::new(
             self.table.file_io.clone(),
             self.table.schema_manager().clone(),
             self.table.schema().id(),
             self.table.schema.fields().to_vec(),
-            read_type,
-            self.data_predicates.clone(),
+            scan_type,
+            predicates,
         )
+        .with_blob_as_descriptor(primary_key)
         .with_file_index_read_enabled(core_options.file_index_read_enabled())
         .with_batch_size(Some(core_options.read_batch_size()?))
         .with_blob_parallelism(self.blob_parallelism)
@@ -592,6 +657,26 @@ impl<'a> PaimonTableRead<'a> {
         .with_table_options(self.table.schema().options().clone())
         .with_mosaic_prefetch(configured_mosaic_prefetch(self.table)?);
         let raw_stream = reader.read(data_splits)?;
+        let raw_stream = if let Some(plan) = blob_plan {
+            plan.finish(
+                raw_stream,
+                &core_options,
+                self.table.file_io.clone(),
+                self.blob_parallelism,
+                self.limit,
+            )
+        } else if primary_key {
+            resolve_primary_key_blob_stream(
+                raw_stream,
+                &read_type,
+                &core_options,
+                self.table.file_io.clone(),
+                self.blob_parallelism,
+                self.limit,
+            )
+        } else {
+            raw_stream
+        };
 
         Ok(Box::pin(async_stream::try_stream! {
             futures::pin_mut!(raw_stream);
@@ -1003,6 +1088,7 @@ impl<'a> PaimonTableRead<'a> {
                 core_options,
                 self.table.file_io.clone(),
                 self.blob_parallelism,
+                self.limit,
             ));
         }
 
@@ -1013,6 +1099,7 @@ impl<'a> PaimonTableRead<'a> {
             core_options,
             self.table.file_io.clone(),
             self.blob_parallelism,
+            self.limit,
         ))
     }
 

Reply via email to