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 86f0cae8 feat: integrate reader memory reservations with DataFusion 
(#914)
86f0cae8 is described below

commit 86f0cae85e0ce2d88def5f8401821b0338c40d9f
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Sep 23 17:59:03 2026 +0800

    feat: integrate reader memory reservations with DataFusion (#914)
---
 .github/workflows/ci.yml                           |   8 +
 crates/integrations/datafusion/README.md           |  14 +
 crates/integrations/datafusion/src/error.rs        |   7 +-
 crates/integrations/datafusion/src/lib.rs          |   1 +
 crates/integrations/datafusion/src/memory.rs       |  57 ++
 .../datafusion/src/physical_plan/audit_log.rs      |   4 +-
 .../datafusion/src/physical_plan/scan.rs           | 159 +++++-
 crates/integrations/datafusion/src/sql_context.rs  |   6 +-
 crates/paimon/src/arrow/format/parquet.rs          | 582 ++++++++++++++++++---
 crates/paimon/src/arrow/read_budget.rs             | 199 +++++--
 crates/paimon/src/error.rs                         |   3 +
 crates/paimon/src/lib.rs                           |   1 +
 crates/paimon/src/resource/memory.rs               | 150 ++++++
 crates/paimon/src/resource/mod.rs                  | 112 ++++
 crates/paimon/src/resource/tests.rs                | 163 ++++++
 crates/paimon/src/table/data_evolution_reader.rs   |  85 ++-
 crates/paimon/src/table/format_table_read.rs       |  19 +-
 crates/paimon/src/table/kv_file_reader.rs          | 103 ++--
 crates/paimon/src/table/read_builder.rs            |  58 +-
 crates/paimon/src/table/table_read.rs              |  38 +-
 crates/paimon/tests/reader_resources_test.rs       | 332 ++++++++++++
 21 files changed, 1922 insertions(+), 179 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1b84a707..fb37e7f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -185,6 +185,14 @@ jobs:
     steps:
       - uses: actions/checkout@v7
 
+      - name: Limit Windows test build memory
+        if: runner.os == 'Windows'
+        shell: pwsh
+        run: |
+          # Full-feature test binaries can exhaust the runner's paging file.
+          "CARGO_BUILD_JOBS=2" >> $env:GITHUB_ENV
+          "CARGO_PROFILE_TEST_DEBUG=line-tables-only" >> $env:GITHUB_ENV
+
       - name: Test
         run: cargo test --locked -p paimon --all-targets --features 
fulltext,vortex
         env:
diff --git a/crates/integrations/datafusion/README.md 
b/crates/integrations/datafusion/README.md
index 94515721..b9583726 100644
--- a/crates/integrations/datafusion/README.md
+++ b/crates/integrations/datafusion/README.md
@@ -24,6 +24,20 @@
 
 This crate contains the integration of [Apache 
DataFusion](https://datafusion.apache.org/) and [Apache 
Paimon](https://paimon.apache.org/).
 
+## Reader memory
+
+Paimon Parquet scans use the memory pool from the executing DataFusion 
`TaskContext`.
+Configure it through a DataFusion `RuntimeEnv`, including with
+`SQLContext::builder().with_runtime_env(runtime_env)`. Scan partitions share 
that
+pool with downstream operators and reserve projected row-group working 
estimates
+before data I/O. A scan consumer cannot spill; exhaustion returns
+`DataFusionError::ResourcesExhausted` after speculative prefetch has been 
deferred.
+
+Downstream consumers account for output batches they retain. The reader leaves
+Arrow buffers unchanged and releases its working reservation when its decoder 
is
+dropped, including cancellation. Metadata, merge state, transient batches and
+allocation overhead are not fully accounted, so the pool is not an RSS limit.
+
 ## REST Catalog views and SQL functions
 
 `SQLContext` can read, execute, create, and drop persistent views and can 
create SQL scalar functions in a Paimon REST Catalog:
diff --git a/crates/integrations/datafusion/src/error.rs 
b/crates/integrations/datafusion/src/error.rs
index 92b27281..bb876f8c 100644
--- a/crates/integrations/datafusion/src/error.rs
+++ b/crates/integrations/datafusion/src/error.rs
@@ -19,5 +19,10 @@ use datafusion::common::error::GenericError;
 
 /// Converts a Paimon error into a DataFusion error.
 pub fn to_datafusion_error(error: paimon::Error) -> 
datafusion::error::DataFusionError {
-    datafusion::error::DataFusionError::External(GenericError::from(error))
+    match error {
+        paimon::Error::ResourceExhausted { message } => {
+            datafusion::error::DataFusionError::ResourcesExhausted(message)
+        }
+        other => 
datafusion::error::DataFusionError::External(GenericError::from(other)),
+    }
 }
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index 5d581137..7043303e 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -51,6 +51,7 @@ mod format_table_truncate;
 mod full_text_search;
 mod hybrid_search;
 mod lateral_vector_search;
+mod memory;
 mod merge_into;
 mod partition_count_pushdown;
 mod physical_plan;
diff --git a/crates/integrations/datafusion/src/memory.rs 
b/crates/integrations/datafusion/src/memory.rs
new file mode 100644
index 00000000..7419b218
--- /dev/null
+++ b/crates/integrations/datafusion/src/memory.rs
@@ -0,0 +1,57 @@
+// 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.
+
+//! Connect reader working-memory reservations to the executing DataFusion 
task.
+
+use std::sync::Arc;
+
+use datafusion::execution::memory_pool::{MemoryConsumer, MemoryReservation};
+use datafusion::execution::TaskContext;
+use paimon::resource::{MemoryPool, ResourceContext};
+
+#[derive(Debug)]
+struct DataFusionMemoryPool {
+    reservation: MemoryReservation,
+}
+
+impl MemoryPool for DataFusionMemoryPool {
+    fn try_reserve(&self, bytes: usize) -> paimon::Result<()> {
+        self.reservation
+            .try_grow(bytes)
+            .map_err(|error| paimon::Error::ResourceExhausted {
+                message: error.to_string(),
+            })
+    }
+
+    fn release(&self, bytes: usize) {
+        self.reservation.shrink(bytes);
+    }
+}
+
+pub(crate) fn reader_resources(
+    context: &TaskContext,
+    partition: usize,
+) -> paimon::Result<ResourceContext> {
+    // Register per execution, not on the reusable plan. Reader working state
+    // cannot spill; downstream operators account for batches they retain.
+    let reservation = 
MemoryConsumer::new(format!("PaimonTableScan[{partition}]"))
+        .with_can_spill(false)
+        .register(context.memory_pool());
+    ResourceContext::builder()
+        .memory_pool(Arc::new(DataFusionMemoryPool { reservation }))
+        .build()
+}
diff --git a/crates/integrations/datafusion/src/physical_plan/audit_log.rs 
b/crates/integrations/datafusion/src/physical_plan/audit_log.rs
index 5fdfeccb..072779b5 100644
--- a/crates/integrations/datafusion/src/physical_plan/audit_log.rs
+++ b/crates/integrations/datafusion/src/physical_plan/audit_log.rs
@@ -62,9 +62,9 @@ impl ExecutionPlan for PaimonAuditLogScan {
     fn execute(
         &self,
         partition: usize,
-        _context: Arc<TaskContext>,
+        context: Arc<TaskContext>,
     ) -> DFResult<SendableRecordBatchStream> {
-        self.inner.execute_with(partition, |read, splits| {
+        self.inner.execute_with(partition, context, |read, splits| {
             AuditLogRead::new(read)?.to_arrow(splits)
         })
     }
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index f75f0f69..531c4fb2 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -1000,6 +1000,7 @@ impl PaimonTableScan {
     pub(crate) fn execute_with(
         &self,
         partition: usize,
+        context: Arc<TaskContext>,
         read_splits: impl FnOnce(TableRead<'_>, &[DataSplit]) -> 
paimon::Result<ArrowRecordBatchStream>
             + Send
             + 'static,
@@ -1021,7 +1022,10 @@ impl PaimonTableScan {
         let parquet_read_budget = Arc::clone(&self.parquet_read_budget);
 
         let fut = async move {
+            let resources = crate::memory::reader_resources(&context, 
partition)
+                .map_err(to_datafusion_error)?;
             let mut read_builder = table.new_read_builder();
+            read_builder.with_resources(resources);
             let runtime_filter_plan = partition_runtime_decoder_filters(
                 &decoder_filters,
                 table.schema().fields(),
@@ -1172,9 +1176,9 @@ impl ExecutionPlan for PaimonTableScan {
     fn execute(
         &self,
         partition: usize,
-        _context: Arc<TaskContext>,
+        context: Arc<TaskContext>,
     ) -> DFResult<SendableRecordBatchStream> {
-        self.execute_with(partition, |read, splits| read.to_arrow(splits))
+        self.execute_with(partition, context, |read, splits| 
read.to_arrow(splits))
     }
 
     fn partition_statistics(&self, partition: Option<usize>) -> 
DFResult<Arc<Statistics>> {
@@ -2308,6 +2312,157 @@ mod tests {
         assert!(result.updated_node.is_none());
     }
 
+    fn memory_scan_fixture() -> (tempfile::TempDir, PaimonTableScan, usize) {
+        use parquet::file::reader::{FileReader, SerializedFileReader};
+        let dir = tempdir().unwrap();
+        let bucket = dir.path().join("bucket-0");
+        fs::create_dir_all(&bucket).unwrap();
+        let file = bucket.join("data.parquet");
+        write_int_parquet_file(&file, vec![("id", (0..8).collect())], None);
+        let metadata = 
SerializedFileReader::new(fs::File::open(&file).unwrap()).unwrap();
+        let estimate = metadata
+            .metadata()
+            .row_group(0)
+            .column(0)
+            .uncompressed_size() as usize;
+        let schema = PaimonSchema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("read.batch-size", "2")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("file").build().unwrap(),
+            Identifier::new("default", "memory_test"),
+            local_file_path(dir.path()),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let split = paimon::DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket))
+            .with_total_buckets(1)
+            .with_data_files(vec![test_data_file(
+                "data.parquet",
+                8,
+                fs::metadata(file).unwrap().len() as i64,
+            )])
+            .build()
+            .unwrap();
+        let scan = PaimonTableScan::new(
+            test_schema(),
+            table,
+            test_read_type(),
+            None,
+            vec![Arc::from(vec![split.clone()]), Arc::from(vec![split])],
+            None,
+            false,
+            None,
+            None,
+            true,
+        );
+        (dir, scan, estimate)
+    }
+
+    fn memory_session(
+        pool: Arc<dyn datafusion::execution::memory_pool::MemoryPool>,
+    ) -> SessionContext {
+        use datafusion::execution::runtime_env::RuntimeEnvBuilder;
+        SessionContext::new_with_config_rt(
+            datafusion::prelude::SessionConfig::new(),
+            RuntimeEnvBuilder::new()
+                .with_memory_pool(pool)
+                .build_arc()
+                .unwrap(),
+        )
+    }
+
+    #[tokio::test]
+    async fn scan_memory_honors_each_execution_pool_and_audit_reads() {
+        use datafusion::error::DataFusionError;
+        use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool};
+        for audit in [false, true] {
+            let (_dir, scan, estimate) = memory_scan_fixture();
+            let plan: Arc<dyn ExecutionPlan> = if audit {
+                
Arc::new(super::super::audit_log::PaimonAuditLogScan::new(scan))
+            } else {
+                Arc::new(scan)
+            };
+            let rejected = Arc::new(GreedyMemoryPool::new(0));
+            let ctx = memory_session(rejected.clone());
+            let mut stream = plan.execute(0, ctx.task_ctx()).unwrap();
+            assert!(matches!(
+                stream.try_next().await,
+                Err(DataFusionError::ResourcesExhausted(_))
+            ));
+            assert!(stream.try_next().await.unwrap().is_none());
+            assert_eq!(rejected.reserved(), 0);
+
+            // Reusing a plan must bind to the current execution, never an 
earlier pool.
+            let admitted = Arc::new(GreedyMemoryPool::new(estimate));
+            let ctx = memory_session(admitted.clone());
+            let output = plan
+                .execute(0, ctx.task_ctx())
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            assert_eq!(collect_ids(&output), (0..8).collect::<Vec<_>>());
+            assert_eq!(
+                admitted.reserved(),
+                0,
+                "returned batches belong to their consumer"
+            );
+            assert_eq!(rejected.reserved(), 0);
+        }
+    }
+
+    #[tokio::test]
+    async fn 
scan_memory_shares_capacity_with_partitions_and_downstream_consumers() {
+        use datafusion::error::DataFusionError;
+        use datafusion::execution::memory_pool::{FairSpillPool, 
MemoryConsumer, MemoryPool};
+        let (_dir, scan, estimate) = memory_scan_fixture();
+        let pool: Arc<dyn MemoryPool> = Arc::new(FairSpillPool::new(2 * 
estimate));
+        let ctx = memory_session(pool.clone());
+        // A spillable peer must not reduce the reader's share: its decoder
+        // cannot spill and must be registered as a non-spillable consumer.
+        let _spillable_peer = MemoryConsumer::new("spillable peer")
+            .with_can_spill(true)
+            .register(&pool);
+        let downstream = MemoryConsumer::new("downstream 
state").register(&pool);
+        downstream.try_grow(estimate).unwrap();
+        let mut first = scan.execute(0, ctx.task_ctx()).unwrap();
+        let retained = first.try_next().await.unwrap().unwrap();
+        assert_eq!(pool.reserved(), 2 * estimate);
+        let mut second = scan.execute(1, ctx.task_ctx()).unwrap();
+        assert!(matches!(
+            second.try_next().await,
+            Err(DataFusionError::ResourcesExhausted(_))
+        ));
+        assert!(second.try_next().await.unwrap().is_none());
+        assert_eq!(
+            pool.reserved(),
+            2 * estimate,
+            "failed admission must roll back"
+        );
+
+        // Cancelling the decoder releases its estimate even if output 
survives.
+        drop(first);
+        assert_eq!(pool.reserved(), estimate);
+        assert_eq!(collect_ids(&[retained]), vec![0, 1]);
+        drop(downstream);
+        assert_eq!(pool.reserved(), 0);
+        let output = scan
+            .execute(1, ctx.task_ctx())
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+        assert_eq!(collect_ids(&output), (0..8).collect::<Vec<_>>());
+        assert_eq!(pool.reserved(), 0);
+    }
+
     #[tokio::test]
     async fn test_scan_applies_retained_runtime_filter_by_default() {
         let tempdir = tempdir().unwrap();
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 34c33035..d13bb93b 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -109,7 +109,11 @@ pub struct SQLContext {
 ///
 /// The builder preserves Paimon's session configuration while allowing callers
 /// to customize DataFusion runtime resources such as memory pools, temporary
-/// directories, and object store registries.
+/// directories, and object store registries. Parquet scans reserve projected
+/// row-group working estimates from the execution's memory pool. These readers
+/// cannot spill; supported downstream DataFusion operators may spill their 
state.
+/// Output batches are accounted by consumers that retain them, not by the 
reader.
+/// The pool does not cover every allocation or bound process RSS.
 ///
 /// # Example
 ///
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index c1c8be80..22d02ce8 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -54,8 +54,8 @@ use parquet::file::statistics::Statistics as 
ParquetStatistics;
 use std::cmp::Ordering;
 use std::collections::HashMap;
 use std::ops::Range;
-use std::sync::Arc;
-use tokio::sync::mpsc;
+use std::sync::{Arc, Mutex};
+use tokio::sync::{mpsc, oneshot};
 
 pub(crate) struct ParquetFormatReader {
     read_budget: Option<Arc<ReadBudget>>,
@@ -723,6 +723,21 @@ impl FormatFileReader for ParquetFormatReader {
             }
         }
 
+        let mut memory_mask = mask.clone();
+        for predicate in &decoder_predicates {
+            memory_mask.union(predicate.projection());
+        }
+        // Resource-aware sequential reads build one row-group stream at a 
time,
+        // so admission precedes its data I/O. Preserve stateful decoder 
filters
+        // across those streams instead of recreating the engine's factory.
+        let shared_filters = if self.read_budget.as_ref().is_some_and(|b| 
b.has_resources()) {
+            std::mem::take(&mut decoder_predicates)
+                .into_iter()
+                .map(SharedParquetPredicate::new)
+                .collect::<Vec<_>>()
+        } else {
+            Vec::new()
+        };
         if !decoder_predicates.is_empty() {
             batch_stream_builder =
                 
batch_stream_builder.with_row_filter(ParquetRowFilter::new(decoder_predicates));
@@ -766,7 +781,8 @@ impl FormatFileReader for ParquetFormatReader {
         //
         // Row-group receivers are consumed in order and buffer one batch each,
         // preserving positional `_ROW_ID`, sort order, and batch backpressure.
-        // Reads with predicates retain the original single-stream path.
+        // Reads with predicates stay sequential; resource-aware reads reserve
+        // each row group before opening its decoder.
         let read_budget = self
             .read_budget
             .as_ref()
@@ -781,7 +797,9 @@ impl FormatFileReader for ParquetFormatReader {
         let selected_row_groups = self
             .read_budget
             .as_ref()
-            .filter(|budget| row_group_parallelism > 1 || 
budget.diagnostics_enabled())
+            .filter(|budget| {
+                row_group_parallelism > 1 || budget.diagnostics_enabled() || 
budget.has_resources()
+            })
             .map(|budget| {
                 let mut row_group_selection = combined_selection;
                 let selected_row_groups = batch_stream_builder
@@ -799,7 +817,12 @@ impl FormatFileReader for ParquetFormatReader {
                         {
                             return None;
                         }
-                        let projected_bytes = 
projected_row_group_bytes(row_group, &mask);
+                        let projection = if budget.has_resources() {
+                            &memory_mask
+                        } else {
+                            &mask
+                        };
+                        let projected_bytes = 
projected_row_group_bytes(row_group, projection);
                         Some((row_group_index, selection, projected_bytes))
                     })
                     .collect::<Vec<_>>();
@@ -825,17 +848,38 @@ impl FormatFileReader for ParquetFormatReader {
                     let Ok(slot) = row_group_tx.reserve().await else {
                         return;
                     };
-                    let permit = match tokio::select! {
+                    let (batch_tx, batch_rx) = mpsc::channel(1);
+                    let (demand_tx, demand_rx) = oneshot::channel();
+                    slot.send((batch_rx, demand_tx));
+                    let admitted = tokio::select! {
                         _ = row_group_tx.closed() => return,
                         permit = read_budget.acquire(projected_bytes) => 
permit,
-                    } {
+                    };
+                    let admitted = match admitted {
+                        Err(Error::ResourceExhausted { .. }) => {
+                            // Speculative prefetch must not fail a read merely
+                            // because an earlier group is still decoding. 
Retry
+                            // once this group is actually requested, after the
+                            // consumer has drained its predecessors. Never 
wait
+                            // for the caller to drop retained output buffers.
+                            tokio::select! {
+                                _ = row_group_tx.closed() => return,
+                                _ = demand_rx => {},
+                            }
+                            tokio::select! {
+                                _ = row_group_tx.closed() => return,
+                                permit = read_budget.acquire(projected_bytes) 
=> permit,
+                            }
+                        }
+                        other => other,
+                    };
+                    let permit = match admitted {
                         Ok(permit) => permit,
                         Err(error) => {
-                            slot.send(Err(error));
+                            let _ = 
batch_tx.send(ParquetRowGroupMessage::Error(error)).await;
                             return;
                         }
                     };
-                    let (batch_tx, batch_rx) = mpsc::channel(1);
                     let row_group_reader = Arc::clone(&shared_reader);
                     let row_group_metadata = reader_metadata.clone();
                     let row_group_mask = mask.clone();
@@ -850,17 +894,17 @@ impl FormatFileReader for ParquetFormatReader {
                         permit,
                         batch_tx,
                     ));
-                    slot.send(Ok(batch_rx));
                 }
             });
             let stream = async_stream::try_stream! {
                 for _ in 0..row_group_count {
-                    let mut batches = row_group_rx.recv().await.ok_or_else(|| {
+                    let (mut batches, demand) = 
row_group_rx.recv().await.ok_or_else(|| {
                         Error::UnexpectedError {
                             message: "Parquet row-group coordinator stopped 
early".to_string(),
                             source: None,
                         }
-                    })??;
+                    })?;
+                    let _ = demand.send(());
                     let mut completed = false;
                     while let Some(message) = batches.recv().await {
                         match message {
@@ -888,6 +932,46 @@ impl FormatFileReader for ParquetFormatReader {
             return Ok(stream.boxed());
         }
 
+        if let Some(budget) = self.read_budget.as_ref().filter(|b| 
b.has_resources()) {
+            let budget = Arc::clone(budget);
+            let selected = selected_row_groups.expect("resource-aware reads 
need a selection plan");
+            let metadata = ArrowReaderMetadata::try_new(
+                batch_stream_builder.metadata().clone(),
+                ArrowReaderOptions::new(),
+            )?;
+            let residual = (!all_enforced).then(|| FilePredicates {
+                predicates: preds.to_vec(),
+                row_filter_factory: None,
+                file_fields: file_fields.to_vec(),
+            });
+            return Ok(async_stream::try_stream! {
+                for (index, selection, estimated_bytes) in selected {
+                    // Foreground and merge reads only try memory admission. 
They
+                    // never hold a shared scheduling slot while awaiting 
another
+                    // merge input, nor wait for output buffers owned 
downstream.
+                    let _permit = budget.reserve_memory(estimated_bytes)?;
+                    let mut stream = build_row_group_stream(
+                        Arc::clone(&shared_reader), file_size, 
metadata.clone(), mask.clone(),
+                        index, batch_size, selection, shared_filters.clone(),
+                    )?;
+                    while let Some(batch) = stream.next().await {
+                        let batch = batch.map_err(Error::from)?;
+                        let batch = match &map_read_plan {
+                            Some(plan) => plan.assemble_batch(&batch)?,
+                            None => batch,
+                        };
+                        let batch = match &residual {
+                            Some(predicates) => 
crate::arrow::residual::filter_record_batch_by_predicates(
+                                batch, predicates, &scan_fields,
+                            )?,
+                            None => batch,
+                        };
+                        yield batch;
+                    }
+                }
+            }.boxed());
+        }
+
         let batch_stream = batch_stream_builder.build()?;
 
         if all_enforced {
@@ -977,58 +1061,139 @@ async fn read_row_group(
     row_group_index: usize,
     batch_size: Option<usize>,
     selection: Option<RowSelection>,
-    _permit: ReadPermit,
+    permit: ReadPermit,
     sender: mpsc::Sender<ParquetRowGroupMessage>,
 ) {
+    let stream = match build_row_group_stream(
+        reader,
+        file_size,
+        reader_metadata,
+        projection,
+        row_group_index,
+        batch_size,
+        selection,
+        Vec::new(),
+    ) {
+        Ok(stream) => stream,
+        Err(error) => {
+            drop(permit);
+            let _ = sender.send(ParquetRowGroupMessage::Error(error)).await;
+            return;
+        }
+    };
+    forward_row_group_batches(stream, sender, Some(permit)).await;
+}
+
+#[derive(Clone)]
+struct SharedParquetPredicate {
+    projection: ProjectionMask,
+    inner: Arc<Mutex<Box<dyn ArrowPredicate>>>,
+}
+
+impl SharedParquetPredicate {
+    fn new(predicate: Box<dyn ArrowPredicate>) -> Self {
+        Self {
+            projection: predicate.projection().clone(),
+            inner: Arc::new(Mutex::new(predicate)),
+        }
+    }
+}
+
+impl ArrowPredicate for SharedParquetPredicate {
+    fn projection(&self) -> &ProjectionMask {
+        &self.projection
+    }
+
+    fn evaluate(&mut self, batch: RecordBatch) -> Result<BooleanArray, 
arrow_schema::ArrowError> {
+        self.inner
+            .lock()
+            .map_err(|_| {
+                arrow_schema::ArrowError::ComputeError(
+                    "Parquet row predicate mutex was poisoned".into(),
+                )
+            })?
+            .evaluate(batch)
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn build_row_group_stream(
+    reader: Arc<dyn FileRead>,
+    file_size: u64,
+    metadata: ArrowReaderMetadata,
+    projection: ProjectionMask,
+    index: usize,
+    batch_size: Option<usize>,
+    selection: Option<RowSelection>,
+    predicates: Vec<SharedParquetPredicate>,
+) -> 
crate::Result<parquet::arrow::async_reader::ParquetRecordBatchStream<ArrowFileReader>>
 {
     let mut builder = ParquetRecordBatchStreamBuilder::new_with_metadata(
         ArrowFileReader::new(file_size, reader),
-        reader_metadata,
+        metadata,
     )
     .with_projection(projection)
-    .with_row_groups(vec![row_group_index]);
+    .with_row_groups(vec![index]);
     if let Some(selection) = selection {
         builder = builder.with_row_selection(selection);
     }
     if let Some(size) = batch_size {
         builder = builder.with_batch_size(size);
     }
-    let mut stream = match builder.build() {
-        Ok(stream) => stream,
-        Err(error) => {
-            let _ = sender
-                .send(ParquetRowGroupMessage::Error(error.into()))
-                .await;
-            return;
-        }
-    };
-
-    forward_row_group_batches(&mut stream, sender).await;
+    if !predicates.is_empty() {
+        builder = builder.with_row_filter(ParquetRowFilter::new(
+            predicates
+                .into_iter()
+                .map(|p| Box::new(p) as Box<dyn ArrowPredicate>)
+                .collect(),
+        ));
+    }
+    builder.build().map_err(Error::from)
 }
 
-async fn forward_row_group_batches<S, E>(
-    mut stream: S,
+fn forward_row_group_batches<S, E>(
+    stream: S,
     sender: mpsc::Sender<ParquetRowGroupMessage>,
-) where
+    permit: Option<ReadPermit>,
+) -> impl std::future::Future<Output = ()>
+where
     S: futures::Stream<Item = std::result::Result<RecordBatch, E>> + Unpin,
     E: Into<Error>,
 {
-    loop {
-        let Ok(slot) = sender.reserve().await else {
-            return;
-        };
-        let next = tokio::select! {
-            _ = sender.closed() => return,
-            next = stream.next() => next,
-        };
-        match next {
-            Some(Ok(batch)) => slot.send(ParquetRowGroupMessage::Batch(batch)),
-            Some(Err(error)) => {
-                slot.send(ParquetRowGroupMessage::Error(error.into()));
-                return;
-            }
-            None => {
-                slot.send(ParquetRowGroupMessage::Done);
+    // Struct fields drop in declaration order, even if the future is cancelled
+    // before its first poll. Keep the working reservation until the decoder 
drops.
+    struct ReservedDecoder<S> {
+        stream: S,
+        _permit: Option<ReadPermit>,
+    }
+    let mut decoder = ReservedDecoder {
+        stream,
+        _permit: permit,
+    };
+    async move {
+        loop {
+            let Ok(slot) = sender.reserve().await else {
                 return;
+            };
+            let next = tokio::select! {
+                _ = sender.closed() => return,
+                next = decoder.stream.next() => next,
+            };
+            match next {
+                Some(Ok(batch)) => {
+                    slot.send(ParquetRowGroupMessage::Batch(batch));
+                }
+                Some(Err(error)) => {
+                    drop(decoder);
+                    slot.send(ParquetRowGroupMessage::Error(error.into()));
+                    return;
+                }
+                None => {
+                    // Release decoder buffers and their working estimate 
before
+                    // the next demanded group retries.
+                    drop(decoder);
+                    slot.send(ParquetRowGroupMessage::Done);
+                    return;
+                }
             }
         }
     }
@@ -3401,7 +3566,7 @@ mod tests {
                     tracked_polls.fetch_add(1, AtomicOrdering::SeqCst);
                 });
         let (tx, mut rx) = mpsc::channel(1);
-        let task = tokio::spawn(forward_row_group_batches(stream, tx));
+        let task = tokio::spawn(forward_row_group_batches(stream, tx, None));
 
         tokio::time::sleep(Duration::from_millis(20)).await;
         assert_eq!(
@@ -3420,6 +3585,62 @@ mod tests {
         task.await.unwrap();
     }
 
+    #[tokio::test]
+    async fn cancelled_row_group_drops_decoder_before_returning_memory() {
+        use crate::resource::ResourceContext;
+
+        struct Decoder {
+            resources: ResourceContext,
+            charge_at_drop: Arc<AtomicUsize>,
+        }
+        impl futures::Stream for Decoder {
+            type Item = Result<RecordBatch, Error>;
+
+            fn poll_next(
+                self: std::pin::Pin<&mut Self>,
+                _: &mut std::task::Context<'_>,
+            ) -> std::task::Poll<Option<Self::Item>> {
+                std::task::Poll::Pending
+            }
+        }
+        impl Drop for Decoder {
+            fn drop(&mut self) {
+                self.charge_at_drop.store(
+                    self.resources.metrics().reserved_memory_bytes,
+                    AtomicOrdering::SeqCst,
+                );
+            }
+        }
+
+        for mode in 0..3 {
+            let resources = 
ResourceContext::builder().memory_limit(8).build().unwrap();
+            let budget = 
ReadBudget::default().with_resources(resources.clone());
+            let permit = budget.reserve_memory(8).unwrap();
+            let charge_at_drop = Arc::new(AtomicUsize::new(usize::MAX));
+            let decoder = Decoder {
+                resources: resources.clone(),
+                charge_at_drop: charge_at_drop.clone(),
+            };
+            let (tx, rx) = mpsc::channel(1);
+            let mut forwarding = Box::pin(forward_row_group_batches(decoder, 
tx, Some(permit)));
+            if mode > 0 {
+                assert!(futures::poll!(&mut forwarding).is_pending());
+            }
+            if mode == 2 {
+                drop(rx);
+                forwarding.as_mut().await;
+            }
+            // Cover an unpolled future, cancellation during I/O, and a closed 
receiver.
+            drop(forwarding);
+            assert_eq!(
+                charge_at_drop.load(AtomicOrdering::SeqCst),
+                8,
+                "mode {mode}"
+            );
+            assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        }
+    }
+
     #[tokio::test]
     async fn test_row_group_batch_forwarding_stops_during_pending_io() {
         let (polled_tx, polled_rx) = tokio::sync::oneshot::channel();
@@ -3431,7 +3652,7 @@ mod tests {
             std::task::Poll::Pending::<Option<Result<RecordBatch, Error>>>
         });
         let (tx, rx) = mpsc::channel(1);
-        let task = tokio::spawn(forward_row_group_batches(stream, tx));
+        let task = tokio::spawn(forward_row_group_batches(stream, tx, None));
 
         polled_rx.await.unwrap();
         drop(rx);
@@ -4333,6 +4554,228 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn resource_admission_precedes_data_reads_on_all_parquet_paths() {
+        use crate::resource::ResourceContext;
+        for (group_rows, parallelism, filtered) in [
+            (16, 4, false),
+            (64, 4, false),
+            (16, 1, false),
+            (16, 4, true),
+        ] {
+            let data = Bytes::from(
+                write_multi_row_group_parquet(group_rows, 64, 
EnabledStatistics::None, true).await,
+            );
+            let tracker = TrackingFileRead::new(data.clone());
+            let resources = 
ResourceContext::builder().memory_limit(0).build().unwrap();
+            let budget = Arc::new(
+                ReadBudget::new(parallelism, 1024 * 1024)
+                    .unwrap()
+                    .with_resources(resources.clone()),
+            );
+            let fields = vec![int_field("id"), int_field("value")];
+            let predicates = filtered.then(|| FilePredicates {
+                predicates: vec![PredicateBuilder::new(&fields)
+                    .greater_than("value", Datum::Int(10))
+                    .unwrap()],
+                row_filter_factory: None,
+                file_fields: fields.clone(),
+            });
+            let mut stream = ParquetFormatReader::with_read_budget(budget)
+                .read_batch_stream(
+                    Box::new(tracker.clone()),
+                    data.len() as u64,
+                    &fields[..1],
+                    predicates.as_ref(),
+                    Some(16),
+                    None,
+                )
+                .await
+                .unwrap();
+            tracker.reset();
+            assert!(matches!(
+                stream.next().await.unwrap(),
+                Err(Error::ResourceExhausted { .. })
+            ));
+            assert!(stream.next().await.is_none());
+            assert_eq!(
+                tracker.bytes_read(),
+                0,
+                "rejected work must not start data I/O"
+            );
+            assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        }
+    }
+
+    #[tokio::test]
+    async fn 
shared_memory_pressure_reduces_prefetch_without_failing_the_read() {
+        use crate::resource::ResourceContext;
+        let data = Bytes::from(
+            write_multi_row_group_parquet(64, 256, EnabledStatistics::Chunk, 
false).await,
+        );
+        let metadata = load_metadata_with_page_index(&data, true);
+        let mask = 
super::ProjectionMask::roots(metadata.file_metadata().schema_descr(), [0]);
+        let limit = metadata
+            .row_groups()
+            .iter()
+            .map(|rg| super::projected_row_group_bytes(rg, &mask))
+            .max()
+            .unwrap() as usize;
+        // Only one row group fits; the consumer owns its output batches.
+        let resources = ResourceContext::builder()
+            .memory_limit(limit)
+            .build()
+            .unwrap();
+        let budget = Arc::new(
+            ReadBudget::new(8, 256 * 1024 * 1024)
+                .unwrap()
+                .with_resources(resources.clone()),
+        );
+        budget.enable_diagnostics();
+        let mut stream = ParquetFormatReader::with_read_budget(budget.clone())
+            .read_batch_stream(
+                Box::new(TrackingFileRead::new(data.clone())),
+                data.len() as u64,
+                &[int_field("id")],
+                None,
+                Some(16),
+                None,
+            )
+            .await
+            .unwrap();
+        let mut ids = Vec::new();
+        while let Some(batch) = tokio::time::timeout(Duration::from_secs(2), 
stream.try_next())
+            .await
+            .unwrap()
+            .unwrap()
+        {
+            ids.extend_from_slice(
+                batch
+                    .column(0)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .values(),
+            );
+        }
+        assert_eq!(ids, (0..256).collect::<Vec<_>>());
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        assert!(resources.metrics().peak_reserved_memory_bytes <= limit);
+        assert_eq!(budget.diagnostics().peak_inflight, 1);
+    }
+
+    #[tokio::test]
+    async fn 
cancelling_shared_budget_prefetch_releases_memory_with_escaped_output() {
+        use crate::resource::ResourceContext;
+        let data = Bytes::from(
+            write_multi_row_group_parquet(64, 256, EnabledStatistics::Chunk, 
false).await,
+        );
+        let resources = ResourceContext::builder()
+            .memory_limit(1024 * 1024)
+            .build()
+            .unwrap();
+        let budget = Arc::new(
+            ReadBudget::new(4, 256 * 1024 * 1024)
+                .unwrap()
+                .with_resources(resources.clone()),
+        );
+        budget.enable_diagnostics();
+        let mut stream = ParquetFormatReader::with_read_budget(budget.clone())
+            .read_batch_stream(
+                Box::new(TrackingFileRead::new(data.clone())),
+                data.len() as u64,
+                &[int_field("id")],
+                None,
+                Some(16),
+                None,
+            )
+            .await
+            .unwrap();
+        let batch = stream.try_next().await.unwrap().unwrap();
+        let escaped = batch.column(0).slice(0, 1);
+        drop(batch);
+        drop(stream);
+        tokio::time::timeout(Duration::from_secs(2), async {
+            while budget.diagnostics().current_inflight != 0 {
+                tokio::task::yield_now().await;
+            }
+        })
+        .await
+        .unwrap();
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        assert_eq!(
+            escaped
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .value(0),
+            0
+        );
+        drop(escaped);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    }
+
+    #[tokio::test]
+    async fn 
filtered_resource_reads_reserve_predicate_columns_and_preserve_row_selection() {
+        use crate::resource::ResourceContext;
+        let data =
+            Bytes::from(write_multi_row_group_parquet(16, 64, 
EnabledStatistics::None, true).await);
+        let metadata = load_metadata_with_page_index(&data, false);
+        let mask = 
super::ProjectionMask::roots(metadata.file_metadata().schema_descr(), [0]);
+        let id_only = 
super::projected_row_group_bytes(&metadata.row_groups()[0], &mask) as usize;
+        let fields = vec![int_field("id"), int_field("value")];
+        let predicates = FilePredicates {
+            predicates: vec![PredicateBuilder::new(&fields)
+                .greater_or_equal("value", Datum::Int(250))
+                .unwrap()],
+            row_filter_factory: None,
+            file_fields: fields.clone(),
+        };
+        for limit in [id_only, 1024 * 1024] {
+            let resources = ResourceContext::builder()
+                .memory_limit(limit)
+                .build()
+                .unwrap();
+            let budget = Arc::new(
+                ReadBudget::new(4, 1024 * 1024)
+                    .unwrap()
+                    .with_resources(resources.clone()),
+            );
+            let mut stream = ParquetFormatReader::with_read_budget(budget)
+                .read_batch_stream(
+                    Box::new(TrackingFileRead::new(data.clone())),
+                    data.len() as u64,
+                    &fields[..1],
+                    Some(&predicates),
+                    Some(8),
+                    Some(vec![RowRange::new(10, 54)]),
+                )
+                .await
+                .unwrap();
+            if limit == id_only {
+                assert!(matches!(
+                    stream.next().await.unwrap(),
+                    Err(Error::ResourceExhausted { .. })
+                ));
+                assert!(stream.next().await.is_none());
+            } else {
+                let mut ids = Vec::new();
+                while let Some(batch) = stream.try_next().await.unwrap() {
+                    ids.extend_from_slice(
+                        batch
+                            .column(0)
+                            .as_any()
+                            .downcast_ref::<Int32Array>()
+                            .unwrap()
+                            .values(),
+                    );
+                }
+                assert_eq!(ids, (25..=54).collect::<Vec<_>>());
+            }
+            assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        }
+    }
+
     #[tokio::test]
     async fn test_sparse_read_buffer_owners_and_cancellation() {
         use crate::io::FileRead;
@@ -5013,6 +5456,7 @@ mod tests {
         .unwrap();
         let props = parquet::file::properties::WriterProperties::builder()
             .set_compression(parquet::basic::Compression::UNCOMPRESSED)
+            .set_max_row_group_row_count(Some(16))
             .build();
         let mut bytes = Vec::new();
         {
@@ -5045,22 +5489,38 @@ mod tests {
             file_fields: fields.clone(),
         };
 
-        ParquetFormatReader::default()
-            .read_batch_stream(
-                Box::new(input.reader().await.unwrap()),
-                file_size,
-                &fields[..1],
-                Some(&predicates),
-                None,
-                None,
-            )
-            .await
-            .unwrap()
-            .try_collect::<Vec<_>>()
-            .await
+        let resources = crate::resource::ResourceContext::builder()
+            .memory_limit(1024 * 1024)
+            .build()
             .unwrap();
+        for reader in [
+            ParquetFormatReader::default(),
+            ParquetFormatReader::with_read_budget(Arc::new(
+                ReadBudget::default().with_resources(resources.clone()),
+            )),
+        ] {
+            order.lock().unwrap().clear();
+            reader
+                .read_batch_stream(
+                    Box::new(input.reader().await.unwrap()),
+                    file_size,
+                    &fields[..1],
+                    Some(&predicates),
+                    None,
+                    None,
+                )
+                .await
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
 
-        assert_eq!(*order.lock().unwrap(), vec!["small", "large"]);
+            assert_eq!(
+                *order.lock().unwrap(),
+                vec!["small", "large", "small", "large"]
+            );
+            assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        }
     }
 
     /// Read `[name]` from the `(id, name, age)` parquet file under 
`predicates`
diff --git a/crates/paimon/src/arrow/read_budget.rs 
b/crates/paimon/src/arrow/read_budget.rs
index 334ac8e3..fc6531bd 100644
--- a/crates/paimon/src/arrow/read_budget.rs
+++ b/crates/paimon/src/arrow/read_budget.rs
@@ -20,6 +20,8 @@ use std::sync::Arc;
 
 use tokio::sync::{OwnedSemaphorePermit, Semaphore};
 
+use crate::resource::{MemoryReservation, ResourceContext};
+
 use crate::spec::PARQUET_ROW_GROUP_MAX_INFLIGHT_BYTES_OPTION;
 
 const BYTE_PERMIT_UNIT: u64 = 1024 * 1024;
@@ -27,19 +29,23 @@ const DEFAULT_BYTE_OPTION: &str = "the row-group read 
budget";
 const DEFAULT_PARALLELISM: usize = 8;
 const DEFAULT_MAX_INFLIGHT_BYTES: u64 = 256 * 1024 * 1024;
 
-/// Resource budget for concurrent row-group reads, bounding both the number of
-/// row groups in flight and their estimated bytes. Parquet holds one per scan;
-/// Mosaic builds one per open file from its own prefetch options.
-#[derive(Debug)]
+/// Scheduling window for concurrent row-group reads, with memory reservations
+/// backed by a [`ResourceContext`]. Slot and prefetch-byte limits control how
+/// much work may start; the context admits the full estimate without clamping.
+/// Without an explicit context, memory reservations have no additional limit
+/// and the existing oversized-row-group scheduling behavior is preserved.
+#[derive(Debug, Clone)]
 pub struct ReadBudget {
     parallelism: usize,
     row_groups: Arc<Semaphore>,
-    bytes: Arc<Semaphore>,
+    // A look-ahead window, independent of shared memory accounting.
+    prefetch: Arc<Semaphore>,
+    resources: Option<ResourceContext>,
     byte_permits: u32,
     byte_permit_unit: u64,
     max_inflight_bytes: u64,
     byte_option: &'static str,
-    oversized_warning_logged: AtomicBool,
+    oversized_warning_logged: Arc<AtomicBool>,
     diagnostics: Arc<ReadBudgetDiagnostics>,
 }
 
@@ -79,8 +85,8 @@ pub(crate) struct ReadBudgetDiagnosticsSnapshot {
 }
 
 impl ReadBudget {
-    /// Scan-wide budget with MiB-granular byte permits, as the Parquet reader
-    /// takes it; its oversized-row-group warning names the Parquet option.
+    /// Scan-wide scheduling window with MiB-granular prefetch permits.
+    /// Its oversized-row-group warning names the Parquet option.
     pub fn new(parallelism: usize, max_inflight_bytes: u64) -> 
crate::Result<Self> {
         Ok(
             Self::with_byte_granularity(parallelism, max_inflight_bytes, 
BYTE_PERMIT_UNIT)?
@@ -88,9 +94,9 @@ impl ReadBudget {
         )
     }
 
-    /// Budget whose byte permits count `byte_permit_unit` bytes each. Mosaic
-    /// charges the exact estimated bytes of a row group, so it passes `1`; at
-    /// most `u32::MAX` permits are tracked, which caps that unit at 4 GiB.
+    /// Scheduling window whose permits count `byte_permit_unit` bytes each.
+    /// Mosaic passes `1`; at most `u32::MAX` permits are tracked, which caps
+    /// that window at 4 GiB. Memory reservations always use the full estimate.
     pub(crate) fn with_byte_granularity(
         parallelism: usize,
         max_inflight_bytes: u64,
@@ -120,12 +126,13 @@ impl ReadBudget {
         Ok(Self {
             parallelism,
             row_groups: Arc::new(Semaphore::new(parallelism)),
-            bytes: Arc::new(Semaphore::new(byte_permits as usize)),
+            prefetch: Arc::new(Semaphore::new(byte_permits as usize)),
+            resources: None,
             byte_permits,
             byte_permit_unit,
             max_inflight_bytes,
             byte_option: DEFAULT_BYTE_OPTION,
-            oversized_warning_logged: AtomicBool::new(false),
+            oversized_warning_logged: Arc::new(AtomicBool::new(false)),
             diagnostics: Arc::new(ReadBudgetDiagnostics::default()),
         })
     }
@@ -191,28 +198,88 @@ impl ReadBudget {
         }
     }
 
-    /// Wait for a row-group slot and the estimated bytes. The Parquet reader
-    /// acquires from its async tasks.
+    /// Bind a shared memory budget while preserving this scheduling window.
+    /// Working estimates draw from this context. An
+    /// oversized row group must still fit the context's memory limit.
+    pub fn with_resources(&self, resources: ResourceContext) -> Self {
+        Self {
+            resources: Some(resources),
+            ..self.clone()
+        }
+    }
+
+    pub(crate) fn has_resources(&self) -> bool {
+        self.resources.is_some()
+    }
+
+    /// Merge inputs need to advance in lockstep. Keep memory admission, while
+    /// disabling background row-group prefetch that could hold slots they 
need.
+    pub(crate) fn without_prefetch(&self) -> Self {
+        Self {
+            parallelism: 1,
+            ..self.clone()
+        }
+    }
+
+    /// Wait for scheduling capacity, then try memory admission. Memory 
rejection
+    /// is immediate: waiting for a caller's retained buffers could deadlock.
     pub(crate) async fn acquire(&self, estimated_bytes: u64) -> 
crate::Result<ReadPermit> {
         let row_group = Arc::clone(&self.row_groups)
             .acquire_owned()
             .await
             .map_err(|_| Self::closed("row-group"))?;
-        let bytes = Arc::clone(&self.bytes)
+        let prefetch = Arc::clone(&self.prefetch)
             .acquire_many_owned(self.byte_permits_for(estimated_bytes))
             .await
-            .map_err(|_| Self::closed("byte"))?;
-        Ok(self.permit(row_group, bytes))
+            .map_err(|_| Self::closed("prefetch"))?;
+        let mut permit = self.reserve_memory(estimated_bytes)?;
+        permit.prefetch = Some((row_group, prefetch));
+        Ok(permit)
     }
 
-    /// Take a row-group slot and the estimated bytes if both are free now. The
-    /// Mosaic reader refills its look-ahead without waiting.
+    /// Take scheduling capacity and memory if both are available now.
     pub(crate) fn try_acquire(&self, estimated_bytes: u64) -> 
Option<ReadPermit> {
         let row_group = Arc::clone(&self.row_groups).try_acquire_owned().ok()?;
-        let bytes = Arc::clone(&self.bytes)
+        let prefetch = Arc::clone(&self.prefetch)
             .try_acquire_many_owned(self.byte_permits_for(estimated_bytes))
             .ok()?;
-        Some(self.permit(row_group, bytes))
+        let mut permit = self.reserve_memory(estimated_bytes).ok()?;
+        permit.prefetch = Some((row_group, prefetch));
+        Some(permit)
+    }
+
+    /// Foreground reads, including merge inputs, must not wait while another
+    /// input holds a scheduling slot. They still reserve from the shared pool.
+    pub(crate) fn reserve_memory(&self, estimated_bytes: u64) -> 
crate::Result<ReadPermit> {
+        let bytes =
+            usize::try_from(estimated_bytes).map_err(|_| 
crate::Error::ResourceExhausted {
+                message: format!("Row-group estimate {estimated_bytes} exceeds 
addressable memory"),
+            })?;
+        let memory = self
+            .resources
+            .as_ref()
+            .map(|resources| {
+                let mut memory = resources.reservation();
+                memory.try_grow(bytes)?;
+                Ok::<_, crate::Error>(memory)
+            })
+            .transpose()?;
+        let diagnostics = self.diagnostics_enabled().then(|| {
+            let current = self
+                .diagnostics
+                .current_inflight
+                .fetch_add(1, Ordering::Relaxed)
+                + 1;
+            self.diagnostics
+                .peak_inflight
+                .fetch_max(current, Ordering::Relaxed);
+            Arc::clone(&self.diagnostics)
+        });
+        Ok(ReadPermit {
+            _memory: memory,
+            prefetch: None,
+            diagnostics,
+        })
     }
 
     /// Block until a row-group slot and the estimated bytes are free. The
@@ -234,7 +301,7 @@ impl ReadBudget {
         {
             log::warn!(
                 "A row group's estimated size ({estimated_bytes} bytes) 
exceeds {} ({} bytes); it \
-                 will consume the entire byte budget and may reduce row-group 
read parallelism; \
+                 will occupy the entire prefetch window and may reduce 
row-group read parallelism; \
                  increase the option if memory allows",
                 self.byte_option,
                 self.max_inflight_bytes
@@ -245,25 +312,6 @@ impl ReadBudget {
             .div_ceil(self.byte_permit_unit)
             .min(u64::from(self.byte_permits)) as u32
     }
-
-    fn permit(&self, row_group: OwnedSemaphorePermit, bytes: 
OwnedSemaphorePermit) -> ReadPermit {
-        let diagnostics = self.diagnostics_enabled().then(|| {
-            let current = self
-                .diagnostics
-                .current_inflight
-                .fetch_add(1, Ordering::Relaxed)
-                + 1;
-            self.diagnostics
-                .peak_inflight
-                .fetch_max(current, Ordering::Relaxed);
-            Arc::clone(&self.diagnostics)
-        });
-        ReadPermit {
-            _row_group: row_group,
-            _bytes: bytes,
-            diagnostics,
-        }
-    }
 }
 
 impl Default for ReadBudget {
@@ -275,8 +323,9 @@ impl Default for ReadBudget {
 
 #[derive(Debug)]
 pub(crate) struct ReadPermit {
-    _row_group: OwnedSemaphorePermit,
-    _bytes: OwnedSemaphorePermit,
+    // Return memory before waking scheduling waiters.
+    _memory: Option<MemoryReservation>,
+    prefetch: Option<(OwnedSemaphorePermit, OwnedSemaphorePermit)>,
     diagnostics: Option<Arc<ReadBudgetDiagnostics>>,
 }
 
@@ -438,4 +487,66 @@ mod tests {
         drop(held);
         waiter.join().unwrap().unwrap();
     }
+
+    #[tokio::test]
+    async fn row_groups_and_other_consumers_share_one_memory_limit() {
+        let resources = 
ResourceContext::builder().memory_limit(32).build().unwrap();
+        let first = ReadBudget::new(2, 1024 * 1024)
+            .unwrap()
+            .with_resources(resources.clone());
+        let second = ReadBudget::new(2, 1024 * 1024)
+            .unwrap()
+            .with_resources(resources.clone());
+        let read = first.acquire(20).await.unwrap();
+        let mut consumer = resources.reservation();
+        consumer.try_grow(8).unwrap();
+        assert!(matches!(
+            second.acquire(5).await,
+            Err(crate::Error::ResourceExhausted { .. })
+        ));
+        assert_eq!(resources.metrics().reserved_memory_bytes, 28);
+        // Rejected memory admission must release both scheduling resources.
+        let other = second.acquire(4).await.unwrap();
+        assert_eq!(resources.metrics().reserved_memory_bytes, 32);
+        drop((read, other, consumer));
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        assert_eq!(resources.metrics().peak_reserved_memory_bytes, 32);
+    }
+
+    #[tokio::test]
+    async fn oversized_prefetch_admission_cannot_bypass_the_memory_limit() {
+        let resources = 
ResourceContext::builder().memory_limit(3).build().unwrap();
+        let budget = ReadBudget::with_byte_granularity(2, 1, 1)
+            .unwrap()
+            .with_resources(resources.clone());
+        assert!(matches!(
+            budget.acquire(4).await,
+            Err(crate::Error::ResourceExhausted { .. })
+        ));
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        let permit = budget.acquire(3).await.unwrap();
+        // The prefetch window is clamped to one; the memory charge is all 
three.
+        assert_eq!(resources.metrics().reserved_memory_bytes, 3);
+        drop(permit);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    }
+
+    #[tokio::test]
+    async fn cancelling_a_prefetch_waiter_does_not_hold_slots_or_memory() {
+        let resources = 
ResourceContext::builder().memory_limit(32).build().unwrap();
+        let budget = ReadBudget::new(2, 1024 * 1024)
+            .unwrap()
+            .with_resources(resources.clone());
+        let first = budget.acquire(16).await.unwrap();
+        let mut waiting = Box::pin(budget.acquire(16));
+        assert!(futures::poll!(&mut waiting).is_pending());
+        assert_eq!(resources.metrics().reserved_memory_bytes, 16);
+        drop(waiting);
+        drop(first);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        let first = budget.acquire(32).await.unwrap();
+        drop(first);
+        assert_eq!(budget.row_groups.available_permits(), 2);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    }
 }
diff --git a/crates/paimon/src/error.rs b/crates/paimon/src/error.rs
index 18696cdd..44aa4a74 100644
--- a/crates/paimon/src/error.rs
+++ b/crates/paimon/src/error.rs
@@ -23,6 +23,9 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
 /// Error type for paimon.
 #[derive(Debug, Snafu)]
 pub enum Error {
+    /// A configured resource budget could not admit the requested reservation.
+    #[snafu(display("Paimon resource exhausted: {}", message))]
+    ResourceExhausted { message: String },
     #[snafu(whatever, display("Paimon data invalid for {}: {:?}", message, 
source))]
     DataInvalid {
         message: String,
diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index 9e6b1340..12c67eb5 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -38,6 +38,7 @@ pub mod full_text;
 pub mod io;
 pub mod lumina;
 mod predicate_stats;
+pub mod resource;
 pub mod spec;
 pub mod table;
 pub mod variant;
diff --git a/crates/paimon/src/resource/memory.rs 
b/crates/paimon/src/resource/memory.rs
new file mode 100644
index 00000000..4be2feea
--- /dev/null
+++ b/crates/paimon/src/resource/memory.rs
@@ -0,0 +1,150 @@
+// 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 std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+use crate::{Error, Result};
+
+/// Optional adapter to an embedding engine's memory budget.
+///
+/// Calls may run concurrently on any runtime thread. Failed reservations must
+/// leave the pool unchanged. Methods must not panic; `release` is infallible 
and
+/// may run during unwinding. The pool must not wait for, or call back into, a
+/// reader to free memory. Reclamation belongs to the consumer, outside the 
pool.
+pub trait MemoryPool: std::fmt::Debug + Send + Sync + 'static {
+    fn try_reserve(&self, bytes: usize) -> Result<()>;
+    fn release(&self, bytes: usize);
+}
+
+/// Exact reservation counters, not measurements of the process's allocations.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub struct ResourceMetrics {
+    pub reserved_memory_bytes: usize,
+    pub peak_reserved_memory_bytes: usize,
+}
+
+#[derive(Debug)]
+pub(super) struct MemoryAccount {
+    limit: usize,
+    external: Option<Arc<dyn MemoryPool>>,
+    // Includes requests awaiting approval from the external pool. This makes
+    // concurrent admission obey the local cap even before external approval.
+    admitted: AtomicUsize,
+    reserved: AtomicUsize,
+    peak: AtomicUsize,
+}
+
+impl MemoryAccount {
+    pub(super) fn new(limit: Option<usize>, external: Option<Arc<dyn 
MemoryPool>>) -> Self {
+        Self {
+            limit: limit.unwrap_or(usize::MAX),
+            external,
+            admitted: AtomicUsize::new(0),
+            reserved: AtomicUsize::new(0),
+            peak: AtomicUsize::new(0),
+        }
+    }
+
+    fn try_reserve(&self, bytes: usize) -> Result<()> {
+        if bytes == 0 {
+            return Ok(());
+        }
+        self.admitted
+            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
+                used.checked_add(bytes).filter(|&next| next <= self.limit)
+            })
+            .map_err(|used| Error::ResourceExhausted {
+                message: format!(
+                    "Cannot reserve {bytes} bytes: {used} bytes already 
reserved or pending, limit {}",
+                    self.limit
+                ),
+            })?;
+        if let Some(pool) = &self.external {
+            if let Err(error) = pool.try_reserve(bytes) {
+                self.admitted.fetch_sub(bytes, Ordering::Relaxed);
+                return Err(error);
+            }
+        }
+        let used = self.reserved.fetch_add(bytes, Ordering::Relaxed) + bytes;
+        self.peak.fetch_max(used, Ordering::Relaxed);
+        Ok(())
+    }
+
+    fn release(&self, bytes: usize) {
+        if bytes == 0 {
+            return;
+        }
+        self.reserved.fetch_sub(bytes, Ordering::Relaxed);
+        if let Some(pool) = &self.external {
+            pool.release(bytes);
+        }
+        self.admitted.fetch_sub(bytes, Ordering::Relaxed);
+    }
+
+    pub(super) fn metrics(&self) -> ResourceMetrics {
+        ResourceMetrics {
+            reserved_memory_bytes: self.reserved.load(Ordering::Relaxed),
+            peak_reserved_memory_bytes: self.peak.load(Ordering::Relaxed),
+        }
+    }
+}
+
+/// Owned reservation. Moving it transfers accounting; dropping it releases it.
+///
+/// This type deliberately does not implement `Clone`. Shared allocations 
should
+/// retain one reservation in their shared owner rather than charge every 
alias.
+#[derive(Debug)]
+pub struct MemoryReservation {
+    account: Arc<MemoryAccount>,
+    size: usize,
+}
+
+impl MemoryReservation {
+    pub(super) fn new(account: Arc<MemoryAccount>) -> Self {
+        Self { account, size: 0 }
+    }
+
+    pub fn size(&self) -> usize {
+        self.size
+    }
+
+    /// Grow atomically with respect to admission. Failure preserves this 
guard.
+    pub fn try_grow(&mut self, bytes: usize) -> Result<()> {
+        self.account.try_reserve(bytes)?;
+        // Account-wide checked admission also guarantees this sum fits.
+        self.size += bytes;
+        Ok(())
+    }
+
+    /// Reserve additional bytes, or release surplus bytes, to reach `size`.
+    pub fn try_resize(&mut self, size: usize) -> Result<()> {
+        if size > self.size {
+            self.try_grow(size - self.size)?;
+        } else {
+            self.account.release(self.size - size);
+            self.size = size;
+        }
+        Ok(())
+    }
+}
+
+impl Drop for MemoryReservation {
+    fn drop(&mut self) {
+        self.account.release(self.size);
+    }
+}
diff --git a/crates/paimon/src/resource/mod.rs 
b/crates/paimon/src/resource/mod.rs
new file mode 100644
index 00000000..24e99f18
--- /dev/null
+++ b/crates/paimon/src/resource/mod.rs
@@ -0,0 +1,112 @@
+// 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.
+
+//! Shared memory reservations for readers and embedding engines.
+//!
+//! Parquet readers reserve each selected row group's projected uncompressed
+//! column size before data I/O, including columns needed by decoder 
predicates.
+//! The decoder owns that reservation and releases it when dropped. Concurrency
+//! slots and the prefetch window remain separate scheduling controls.
+//!
+//! Consumers are responsible for reserving memory they retain. Output batches
+//! are ordinary Arrow batches: a downstream consumer that holds them must 
reserve
+//! its own memory. The reader does not wrap their buffers or charge their 
aliases.
+//!
+//! Reservations are accounting, not an allocator or an RSS limit. Row-group
+//! charges are estimates; metadata, merge state, transient batches and 
allocation
+//! overhead are not fully covered, and actual memory can exceed the estimates.
+
+mod memory;
+pub use memory::{MemoryPool, MemoryReservation, ResourceMetrics};
+
+use std::sync::Arc;
+
+use self::memory::MemoryAccount;
+use crate::Result;
+
+/// Memory budget shared by consumers in one logical operation.
+///
+/// Clones share admission and metrics. Each consumer owns its reservations and
+/// releases them when its working state is dropped. Retained output batches 
are
+/// the caller's responsibility, including when they outlive the reader.
+///
+/// ```
+/// use paimon::resource::ResourceContext;
+///
+/// let resources = ResourceContext::builder()
+///     .memory_limit(256 * 1024 * 1024)
+///     .build()?;
+/// // Pass resources.clone() to ReadBuilder::with_resources.
+/// // Other consumers reserve from the same budget for their own retained 
state.
+/// let mut reservation = resources.reservation();
+/// reservation.try_grow(1024)?;
+/// assert_eq!(resources.metrics().reserved_memory_bytes, 1024);
+/// drop(reservation);
+/// assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+/// # Ok::<(), paimon::Error>(())
+/// ```
+#[derive(Clone, Debug)]
+pub struct ResourceContext {
+    memory: Arc<MemoryAccount>,
+}
+
+impl ResourceContext {
+    pub fn builder() -> ResourceContextBuilder {
+        ResourceContextBuilder::default()
+    }
+
+    /// Create an initially empty reservation owned by a consumer.
+    pub fn reservation(&self) -> MemoryReservation {
+        MemoryReservation::new(Arc::clone(&self.memory))
+    }
+
+    /// Read accounting counters. The two counters are sampled independently.
+    pub fn metrics(&self) -> ResourceMetrics {
+        self.memory.metrics()
+    }
+}
+
+/// Configure a local limit and an optional embedding-engine pool.
+#[derive(Default, Debug)]
+pub struct ResourceContextBuilder {
+    memory_limit: Option<usize>,
+    memory_pool: Option<Arc<dyn MemoryPool>>,
+}
+
+impl ResourceContextBuilder {
+    /// Limit outstanding reservations. Zero allows only zero-byte 
reservations.
+    /// Omitting this setting leaves the context without a local limit.
+    pub fn memory_limit(mut self, bytes: usize) -> Self {
+        self.memory_limit = Some(bytes);
+        self
+    }
+
+    /// Also reserve from an external pool. Both limits must permit each 
request.
+    pub fn memory_pool(mut self, pool: Arc<dyn MemoryPool>) -> Self {
+        self.memory_pool = Some(pool);
+        self
+    }
+
+    pub fn build(self) -> Result<ResourceContext> {
+        Ok(ResourceContext {
+            memory: Arc::new(MemoryAccount::new(self.memory_limit, 
self.memory_pool)),
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/paimon/src/resource/tests.rs 
b/crates/paimon/src/resource/tests.rs
new file mode 100644
index 00000000..7db2d122
--- /dev/null
+++ b/crates/paimon/src/resource/tests.rs
@@ -0,0 +1,163 @@
+// 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 std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, Barrier};
+
+use super::{MemoryPool, ResourceContext};
+use crate::{Error, Result};
+
+fn used(context: &ResourceContext) -> usize {
+    context.metrics().reserved_memory_bytes
+}
+
+#[test]
+fn reservations_share_the_limit_and_failed_growth_is_atomic() {
+    let resources = 
ResourceContext::builder().memory_limit(8).build().unwrap();
+    let mut first = resources.reservation();
+    let mut second = resources.clone().reservation();
+    first.try_grow(8).unwrap();
+    assert!(matches!(
+        second.try_grow(1),
+        Err(Error::ResourceExhausted { .. })
+    ));
+    assert_eq!(second.size(), 0);
+    first.try_resize(3).unwrap();
+    second.try_grow(5).unwrap();
+    assert!(first.try_resize(9).is_err());
+    assert_eq!(first.size(), 3);
+    assert_eq!(used(&resources), 8);
+    drop(first);
+    assert_eq!(used(&resources), 5);
+    drop(second);
+    assert_eq!(used(&resources), 0);
+    assert_eq!(resources.metrics().peak_reserved_memory_bytes, 8);
+}
+
+#[test]
+fn zero_limit_and_address_space_overflow_are_rejected() {
+    let zero = ResourceContext::builder().memory_limit(0).build().unwrap();
+    let mut reservation = zero.reservation();
+    reservation.try_grow(0).unwrap();
+    assert!(reservation.try_grow(1).is_err());
+    assert_eq!(used(&zero), 0);
+
+    let unlimited = ResourceContext::builder().build().unwrap();
+    let mut reservation = unlimited.reservation();
+    // Reservations are accounting; this does not allocate memory.
+    reservation.try_grow(usize::MAX).unwrap();
+    assert!(reservation.try_grow(1).is_err());
+    assert!(unlimited.reservation().try_grow(1).is_err());
+    assert_eq!(used(&unlimited), usize::MAX);
+    drop(reservation);
+    assert_eq!(used(&unlimited), 0);
+}
+
+#[derive(Debug)]
+struct EnginePool {
+    limit: usize,
+    reserved: AtomicUsize,
+    released: AtomicUsize,
+}
+
+impl MemoryPool for EnginePool {
+    fn try_reserve(&self, bytes: usize) -> Result<()> {
+        self.reserved
+            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |used| {
+                used.checked_add(bytes).filter(|next| *next <= self.limit)
+            })
+            .map_err(|_| Error::ResourceExhausted {
+                message: "engine budget".to_string(),
+            })?;
+        Ok(())
+    }
+
+    fn release(&self, bytes: usize) {
+        assert!(self.reserved.fetch_sub(bytes, Ordering::Relaxed) >= bytes);
+        self.released.fetch_add(bytes, Ordering::Relaxed);
+    }
+}
+
+#[test]
+fn external_pool_failure_rolls_back_local_admission() {
+    let pool = Arc::new(EnginePool {
+        limit: 12,
+        reserved: AtomicUsize::new(0),
+        released: AtomicUsize::new(0),
+    });
+    let left = ResourceContext::builder()
+        .memory_limit(10)
+        .memory_pool(pool.clone())
+        .build()
+        .unwrap();
+    let right = ResourceContext::builder()
+        .memory_limit(10)
+        .memory_pool(pool.clone())
+        .build()
+        .unwrap();
+    let mut first = left.reservation();
+    let mut second = right.reservation();
+    first.try_grow(8).unwrap();
+    let error = second.try_grow(5).unwrap_err();
+    assert!(matches!(error, Error::ResourceExhausted { message } if message == 
"engine budget"));
+    assert_eq!(used(&right), 0);
+    assert_eq!(right.metrics().peak_reserved_memory_bytes, 0);
+    second.try_grow(4).unwrap();
+    assert!(first.try_grow(3).is_err());
+    drop(first);
+    // This reaches the local limit, proving the rejected request was rolled 
back.
+    second.try_grow(6).unwrap();
+    assert_eq!(used(&right), 10);
+    assert_eq!(pool.reserved.load(Ordering::Relaxed), 10);
+    drop(second);
+    assert_eq!(pool.reserved.load(Ordering::Relaxed), 0);
+    assert_eq!(pool.released.load(Ordering::Relaxed), 18);
+}
+
+#[test]
+fn concurrent_reservations_cannot_overcommit() {
+    const THREADS: usize = 16;
+    let resources = 
ResourceContext::builder().memory_limit(32).build().unwrap();
+    let start = Arc::new(Barrier::new(THREADS + 1));
+    let ready = Arc::new(Barrier::new(THREADS + 1));
+    let release = Arc::new(Barrier::new(THREADS + 1));
+    let threads: Vec<_> = (0..THREADS)
+        .map(|_| {
+            let resources = resources.clone();
+            let (start, ready, release) = (start.clone(), ready.clone(), 
release.clone());
+            std::thread::spawn(move || {
+                let mut reservation = resources.reservation();
+                start.wait();
+                let admitted = reservation.try_grow(8).is_ok();
+                ready.wait();
+                release.wait();
+                admitted
+            })
+        })
+        .collect();
+    start.wait();
+    ready.wait();
+    assert_eq!(used(&resources), 32);
+    release.wait();
+    let admitted = threads
+        .into_iter()
+        .map(|thread| usize::from(thread.join().unwrap()))
+        .sum::<usize>();
+    assert_eq!(admitted, 4);
+    assert_eq!(used(&resources), 0);
+    assert_eq!(resources.metrics().peak_reserved_memory_bytes, 32);
+}
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index fb775ae0..d9e4ad6f 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -991,7 +991,9 @@ impl DataEvolutionReader {
             let source_parquet_read_budget = if active_source_indices.len() == 
1 {
                 parquet_read_budget.clone()
             } else {
-                None
+                parquet_read_budget.as_ref()
+                    .filter(|budget| budget.has_resources())
+                    .map(|budget| Arc::new(budget.without_prefetch()))
             };
             let mut source_streams: Vec<Option<ArrowRecordBatchStream>> = 
source_plan
                 .sources
@@ -7605,6 +7607,87 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn test_evolution_multi_source_merge_preserves_memory_admission() {
+        let tempdir = tempdir().unwrap();
+        let table_path = local_file_path(tempdir.path());
+        let bucket_dir = tempdir.path().join("bucket-0");
+        fs::create_dir_all(&bucket_dir).unwrap();
+
+        let id_path = bucket_dir.join("id.parquet");
+        write_int_parquet_file(&id_path, vec![("id", vec![1, 2, 3, 4])], 
Some(2));
+        let value_path = bucket_dir.join("value.parquet");
+        write_int_parquet_file(&value_path, vec![("value", vec![10, 20, 30, 
40])], Some(2));
+
+        let table = two_col_evolution_table(table_path);
+        let split = DataSplitBuilder::new()
+            .with_snapshot(1)
+            .with_partition(BinaryRow::new(0))
+            .with_bucket(0)
+            .with_bucket_path(local_file_path(&bucket_dir))
+            .with_total_buckets(1)
+            .with_data_files(vec![
+                data_file_meta_with_path(
+                    "id.parquet",
+                    0,
+                    4,
+                    1,
+                    id_path.metadata().unwrap().len() as i64,
+                    Some(vec!["id"]),
+                ),
+                data_file_meta_with_path(
+                    "value.parquet",
+                    0,
+                    4,
+                    2,
+                    value_path.metadata().unwrap().len() as i64,
+                    Some(vec!["value"]),
+                ),
+            ])
+            .build()
+            .unwrap();
+
+        for limit in [None, Some(0), Some(1024 * 1024)] {
+            let resources = limit.map(|limit| {
+                crate::resource::ResourceContext::builder()
+                    .memory_limit(limit)
+                    .build()
+                    .unwrap()
+            });
+            let mut builder = table.new_read_builder();
+            builder.with_parquet_read_budget(Arc::new(ReadBudget::new(2, 
1).unwrap()));
+            if let Some(resources) = &resources {
+                builder.with_resources(resources.clone());
+            }
+            let result = tokio::time::timeout(
+                std::time::Duration::from_secs(5),
+                builder
+                    .new_read()
+                    .unwrap()
+                    .to_arrow(std::slice::from_ref(&split))
+                    .unwrap()
+                    .try_collect::<Vec<_>>(),
+            )
+            .await
+            .expect("column merges must not wait on shared prefetch permits");
+
+            if limit == Some(0) {
+                assert!(matches!(result, Err(Error::ResourceExhausted { .. 
})));
+            } else {
+                let batches = result.unwrap();
+                assert_eq!(collect_int_values(&batches, "id"), vec![1, 2, 3, 
4]);
+                assert_eq!(collect_int_values(&batches, "value"), vec![10, 20, 
30, 40]);
+                if let Some(resources) = &resources {
+                    let peak = resources.metrics().peak_reserved_memory_bytes;
+                    assert!(peak > 0 && peak <= limit.unwrap());
+                }
+            }
+            if let Some(resources) = &resources {
+                assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+            }
+        }
+    }
+
     #[tokio::test]
     async fn 
test_evolution_input_decode_honors_read_batch_size_on_all_file_paths() {
         let tempdir = tempdir().unwrap();
diff --git a/crates/paimon/src/table/format_table_read.rs 
b/crates/paimon/src/table/format_table_read.rs
index e419e78e..125a2a07 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -24,6 +24,7 @@ use super::{ArrowRecordBatchStream, Table};
 use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
 use crate::arrow::partition::partition_array;
 use crate::arrow::{build_target_arrow_schema, ReadBudget};
+use crate::resource::ResourceContext;
 use crate::spec::{DataField, Predicate};
 use crate::{DataSplit, Error};
 use arrow_array::{RecordBatch, RecordBatchOptions};
@@ -38,6 +39,7 @@ pub(crate) struct FormatTableRead<'a> {
     data_predicates: Vec<Predicate>,
     row_filter_factory: Option<Arc<dyn crate::arrow::RowFilterFactory>>,
     parquet_read_budget: Option<Arc<ReadBudget>>,
+    resources: Option<ResourceContext>,
     limit: Option<usize>,
     blob_parallelism: usize,
 }
@@ -55,6 +57,7 @@ impl<'a> FormatTableRead<'a> {
             data_predicates,
             row_filter_factory: None,
             parquet_read_budget: None,
+            resources: None,
             limit,
             blob_parallelism: DEFAULT_BLOB_READ_PARALLELISM,
         }
@@ -95,11 +98,19 @@ impl<'a> FormatTableRead<'a> {
         self
     }
 
+    pub(crate) fn with_resources(&mut self, resources: ResourceContext) {
+        self.resources = Some(resources);
+    }
+
     fn parquet_read_budget(&self) -> crate::Result<Arc<ReadBudget>> {
-        match &self.parquet_read_budget {
-            Some(budget) => Ok(Arc::clone(budget)),
-            None => configured_parquet_read_budget(self.table),
-        }
+        let budget = match &self.parquet_read_budget {
+            Some(budget) => Arc::clone(budget),
+            None => configured_parquet_read_budget(self.table)?,
+        };
+        Ok(match &self.resources {
+            Some(resources) => 
Arc::new(budget.with_resources(resources.clone())),
+            None => budget,
+        })
     }
 
     pub(crate) fn to_arrow(
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 36ca7ed8..174b6542 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -616,7 +616,9 @@ impl KeyValueFileReader {
                     let group_parquet_read_budget = if input_stream_count == 1 
{
                         config.parquet_read_budget.clone()
                     } else {
-                        None
+                        config.parquet_read_budget.as_ref()
+                            .filter(|budget| budget.has_resources())
+                            .map(|budget| Arc::new(budget.without_prefetch()))
                     };
                     let mut file_streams: Vec<ArrowRecordBatchStream> = 
Vec::new();
 
@@ -2035,43 +2037,70 @@ mod tests {
         let planned = plan_merge_groups(std::slice::from_ref(&split), 
Some(&comparator), false);
         assert_eq!(planned.len(), 1);
         assert_eq!(planned[0].len(), 2);
-        let core_options = table.schema().core_options();
-        let reader = KeyValueFileReader::new(
-            table.file_io().clone(),
-            KeyValueReadConfig {
-                table_name: table.identifier().full_name(),
-                table_options: table.schema().options().clone(),
-                schema_manager: table.schema_manager().clone(),
-                table_schema_id: table.schema().id(),
-                table_fields: table.schema().fields().to_vec(),
-                read_type: table.schema().fields().to_vec(),
-                predicates: Vec::new(),
-                primary_keys: table.schema().trimmed_primary_keys(),
-                table_primary_keys: table.schema().primary_keys().to_vec(),
-                merge_engine: core_options.merge_engine().unwrap(),
-                sequence_fields: Vec::new(),
-                read_batch_size: core_options.read_batch_size().unwrap(),
-                merge_splits: false,
-                max_merge_input_streams: None,
-                parquet_read_budget: Some(Arc::new(ReadBudget::new(2, 256 << 
20).unwrap())),
-                mosaic_prefetch: MosaicPrefetchOptions::default(),
-            },
-        );
-        let batches = tokio::time::timeout(
-            std::time::Duration::from_secs(5),
-            reader
-                .read(std::slice::from_ref(split.as_ref()))
-                .unwrap()
-                .try_collect::<Vec<_>>(),
-        )
-        .await
-        .expect("multiple sorted-run inputs must not deadlock on shared 
Parquet permits")
-        .unwrap();
+        for limit in [None, Some(0), Some(1024 * 1024)] {
+            let core_options = table.schema().core_options();
+            let budget = ReadBudget::new(2, 256 << 20).unwrap();
+            let resources = limit.map(|limit| {
+                crate::resource::ResourceContext::builder()
+                    .memory_limit(limit)
+                    .build()
+                    .unwrap()
+            });
+            let budget = match &resources {
+                Some(resources) => budget.with_resources(resources.clone()),
+                None => budget,
+            };
+            let reader = KeyValueFileReader::new(
+                table.file_io().clone(),
+                KeyValueReadConfig {
+                    table_name: table.identifier().full_name(),
+                    table_options: table.schema().options().clone(),
+                    schema_manager: table.schema_manager().clone(),
+                    table_schema_id: table.schema().id(),
+                    table_fields: table.schema().fields().to_vec(),
+                    read_type: table.schema().fields().to_vec(),
+                    predicates: Vec::new(),
+                    primary_keys: table.schema().trimmed_primary_keys(),
+                    table_primary_keys: table.schema().primary_keys().to_vec(),
+                    merge_engine: core_options.merge_engine().unwrap(),
+                    sequence_fields: Vec::new(),
+                    read_batch_size: core_options.read_batch_size().unwrap(),
+                    merge_splits: false,
+                    max_merge_input_streams: None,
+                    parquet_read_budget: Some(Arc::new(budget)),
+                    mosaic_prefetch: MosaicPrefetchOptions::default(),
+                },
+            );
+            let result = tokio::time::timeout(
+                std::time::Duration::from_secs(5),
+                reader
+                    .read(std::slice::from_ref(split.as_ref()))
+                    .unwrap()
+                    .try_collect::<Vec<_>>(),
+            )
+            .await
+            .expect("multiple sorted-run inputs must not deadlock on shared 
Parquet permits");
+            if limit == Some(0) {
+                assert!(matches!(
+                    result,
+                    Err(crate::Error::ResourceExhausted { .. })
+                ));
+            } else {
+                let batches = result.unwrap();
 
-        assert_eq!(
-            batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
-            128
-        );
+                assert_eq!(
+                    batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
+                    128
+                );
+                drop(batches);
+                if let Some(resources) = &resources {
+                    assert!(resources.metrics().peak_reserved_memory_bytes > 
0);
+                }
+            }
+            if let Some(resources) = resources {
+                assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+            }
+        }
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 063b898c..3561616a 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -27,6 +27,7 @@ use super::partition_filter::PartitionFilter;
 use super::table_read::{configured_parquet_read_budget, TableRead};
 use super::{Table, TableScan};
 use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
+use crate::resource::ResourceContext;
 use crate::spec::{CoreOptions, DataField, Predicate};
 use crate::table::source::RowRange;
 use crate::{Error, Result};
@@ -112,7 +113,10 @@ fn normalize_filter(table: &Table, filter: Predicate) -> 
NormalizedFilter {
 /// Rust keeps a names-based projection API for ergonomics, while aligning the
 /// resulting read semantics with Java Paimon's order-preserving projection.
 #[derive(Debug, Clone)]
-pub struct ReadBuilder<'a>(ReadBuilderKind<'a>);
+pub struct ReadBuilder<'a> {
+    kind: ReadBuilderKind<'a>,
+    resources: Option<ResourceContext>,
+}
 
 #[derive(Debug, Clone)]
 enum ReadBuilderKind<'a> {
@@ -122,13 +126,27 @@ enum ReadBuilderKind<'a> {
 
 impl<'a> ReadBuilder<'a> {
     pub(crate) fn new(table: &'a Table) -> Self {
-        if table.is_format_table() {
-            Self(ReadBuilderKind::Format(FormatReadBuilder::new(table)))
+        let kind = if table.is_format_table() {
+            ReadBuilderKind::Format(FormatReadBuilder::new(table))
         } else {
-            Self(ReadBuilderKind::Paimon(PaimonReadBuilder::new(table)))
+            ReadBuilderKind::Paimon(PaimonReadBuilder::new(table))
+        };
+        Self {
+            kind,
+            resources: None,
         }
     }
 
+    /// Share Parquet working-memory reservations across reads.
+    ///
+    /// Row-group data reads draw from this context; callers account for 
retained outputs.
+    /// See [`ResourceContext`] for the estimate-based accounting contract.
+    /// When admission fails, the stream returns a resource-exhausted error 
and ends.
+    pub fn with_resources(&mut self, resources: ResourceContext) -> &mut Self {
+        self.resources = Some(resources);
+        self
+    }
+
     /// Set column projection by name. Output order follows the 
caller-specified order.
     /// An empty list is a valid zero-column projection.
     ///
@@ -139,7 +157,7 @@ impl<'a> ReadBuilder<'a> {
     /// case-insensitively, or a case-fold duplicate/ambiguity — surface from
     /// [`new_read`](Self::new_read) using the effective case sensitivity.
     pub fn with_projection(&mut self, columns: &[&str]) -> Result<&mut Self> {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_projection(columns)?;
             }
@@ -165,7 +183,7 @@ impl<'a> ReadBuilder<'a> {
     /// retroactively change a predicate already passed to
     /// [`with_filter`](Self::with_filter).
     pub fn with_case_sensitive(&mut self, case_sensitive: bool) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_case_sensitive(case_sensitive);
             }
@@ -179,7 +197,7 @@ impl<'a> ReadBuilder<'a> {
     /// Set the full read type, including nested field pruning or 
connector-defined
     /// logical read types such as Variant extractions.
     pub fn with_read_type(&mut self, read_type: Vec<DataField>) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_read_type(read_type);
             }
@@ -192,7 +210,7 @@ impl<'a> ReadBuilder<'a> {
 
     /// Set a filter predicate for scan planning and conservative read pruning.
     pub fn with_filter(&mut self, filter: Predicate) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_filter(filter);
             }
@@ -205,7 +223,7 @@ impl<'a> ReadBuilder<'a> {
 
     /// Whether a translated predicate is exact at the table-provider boundary.
     pub fn is_exact_filter_pushdown(&self, filter: &Predicate) -> bool {
-        match &self.0 {
+        match &self.kind {
             ReadBuilderKind::Paimon(builder) => 
builder.is_exact_filter_pushdown(filter),
             ReadBuilderKind::Format(builder) => 
builder.is_exact_filter_pushdown(filter),
         }
@@ -214,7 +232,7 @@ impl<'a> ReadBuilder<'a> {
     /// Set Data Evolution row ID ranges `[from, to]` (inclusive).
     /// An empty vector selects no rows. Format tables are not supported.
     pub fn with_row_ranges(&mut self, ranges: Vec<RowRange>) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_row_ranges(ranges);
             }
@@ -228,7 +246,7 @@ impl<'a> ReadBuilder<'a> {
     /// Push a row-limit hint down to scan planning. Data-evolution reads also
     /// enforce this limit before resolving BLOB payloads.
     pub fn with_limit(&mut self, limit: usize) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_limit(limit);
             }
@@ -247,7 +265,7 @@ impl<'a> ReadBuilder<'a> {
                 source: None,
             });
         }
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_blob_parallelism(blob_parallelism);
             }
@@ -261,7 +279,7 @@ impl<'a> ReadBuilder<'a> {
     /// Inject a Parquet budget shared with sibling scan partitions.
     #[doc(hidden)]
     pub fn with_parquet_read_budget(&mut self, budget: 
Arc<crate::arrow::ReadBudget>) -> &mut Self {
-        match &mut self.0 {
+        match &mut self.kind {
             ReadBuilderKind::Paimon(builder) => {
                 builder.with_parquet_read_budget(budget);
             }
@@ -274,7 +292,7 @@ impl<'a> ReadBuilder<'a> {
 
     /// Create a table scan. Call [TableScan::plan] to get splits.
     pub fn new_scan(&self) -> TableScan<'a> {
-        match &self.0 {
+        match &self.kind {
             ReadBuilderKind::Paimon(builder) => builder.new_scan(),
             ReadBuilderKind::Format(builder) => builder.new_scan(),
         }
@@ -293,7 +311,7 @@ impl<'a> ReadBuilder<'a> {
         start_exclusive: i64,
         end_inclusive: i64,
     ) -> IncrementalScan<'a> {
-        match &self.0 {
+        match &self.kind {
             ReadBuilderKind::Paimon(builder) => IncrementalScan::new(
                 builder.table,
                 builder.new_scan(),
@@ -310,10 +328,14 @@ impl<'a> ReadBuilder<'a> {
 
     /// Create a table read for consuming splits (e.g. from a scan plan).
     pub fn new_read(&self) -> Result<TableRead<'a>> {
-        match &self.0 {
+        let read = match &self.kind {
             ReadBuilderKind::Paimon(builder) => builder.new_read(),
             ReadBuilderKind::Format(builder) => builder.new_read(),
-        }
+        }?;
+        Ok(match &self.resources {
+            Some(resources) => read.with_resources(resources.clone()),
+            None => read,
+        })
     }
 }
 
@@ -761,7 +783,7 @@ mod tests {
     use test_utils::{local_file_path, test_data_file, write_int_parquet_file};
 
     fn paimon_builder<'a, 'b>(builder: &'b ReadBuilder<'a>) -> &'b 
PaimonReadBuilder<'a> {
-        match &builder.0 {
+        match &builder.kind {
             ReadBuilderKind::Paimon(inner) => inner,
             ReadBuilderKind::Format(_) => panic!("expected Paimon read 
builder"),
         }
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index 70576d30..3313df0e 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -26,6 +26,7 @@ use crate::arrow::build_target_arrow_schema;
 use crate::arrow::format::blob::DEFAULT_BLOB_READ_PARALLELISM;
 use crate::arrow::format::MosaicPrefetchOptions;
 use crate::arrow::ReadBudget;
+use crate::resource::ResourceContext;
 use crate::spec::{
     BigIntType, CoreOptions, DataField, DataType, MergeEngine, Predicate, 
TinyIntType,
     ROW_KIND_FIELD_ID, ROW_KIND_FIELD_NAME, SEQUENCE_NUMBER_FIELD_ID, 
SEQUENCE_NUMBER_FIELD_NAME,
@@ -133,6 +134,18 @@ impl<'a> TableRead<'a> {
         }
     }
 
+    /// Share Parquet working-memory reservations with other consumers.
+    ///
+    /// Callers account for output batches they retain. Budget exhaustion is a
+    /// terminal stream error. See [`ResourceContext`] for the accounting 
scope.
+    pub fn with_resources(mut self, resources: ResourceContext) -> Self {
+        match &mut self.0 {
+            TableReadKind::Paimon(read) => read.resources = Some(resources),
+            TableReadKind::Format(read) => read.with_resources(resources),
+        }
+        self
+    }
+
     /// Set a filter predicate.
     pub fn with_filter(self, filter: Predicate) -> Self {
         match self.0 {
@@ -287,6 +300,7 @@ struct PaimonTableRead<'a> {
     data_predicates: Vec<Predicate>,
     row_filter_factory: Option<Arc<dyn crate::arrow::RowFilterFactory>>,
     parquet_read_budget: Option<Arc<ReadBudget>>,
+    resources: Option<ResourceContext>,
     data_file_read_timing: Option<Arc<DataFileReadTiming>>,
     blob_parallelism: usize,
     limit: Option<usize>,
@@ -305,6 +319,7 @@ impl<'a> PaimonTableRead<'a> {
             data_predicates,
             row_filter_factory: None,
             parquet_read_budget: None,
+            resources: None,
             data_file_read_timing: None,
             blob_parallelism: DEFAULT_BLOB_READ_PARALLELISM,
             limit: None,
@@ -362,10 +377,14 @@ impl<'a> PaimonTableRead<'a> {
     }
 
     fn parquet_read_budget(&self) -> crate::Result<Arc<ReadBudget>> {
-        match &self.parquet_read_budget {
-            Some(budget) => Ok(Arc::clone(budget)),
-            None => configured_parquet_read_budget(self.table),
-        }
+        let budget = match &self.parquet_read_budget {
+            Some(budget) => Arc::clone(budget),
+            None => configured_parquet_read_budget(self.table)?,
+        };
+        Ok(match &self.resources {
+            Some(resources) => 
Arc::new(budget.with_resources(resources.clone())),
+            None => budget,
+        })
     }
 
     /// Returns an [`ArrowRecordBatchStream`] for an incremental scan plan.
@@ -808,6 +827,10 @@ impl<'a> PaimonTableRead<'a> {
                 });
             }
         }
+        let budget = self.parquet_read_budget()?;
+        let parquet_read_budget = budget
+            .has_resources()
+            .then(|| Arc::new(budget.without_prefetch()));
         let reader = KeyValueFileReader::new(
             self.table.file_io.clone(),
             KeyValueReadConfig {
@@ -829,10 +852,9 @@ impl<'a> PaimonTableRead<'a> {
                 read_batch_size: core_options.read_batch_size()?,
                 merge_splits: true,
                 max_merge_input_streams: Some(MAX_MERGE_INPUT_STREAMS),
-                // Diff primes the before and after streams in sequence. 
Keeping
-                // a row-group permit across yielded batches can otherwise let
-                // the first side block the second side indefinitely.
-                parquet_read_budget: None,
+                // Diff advances before/after in lockstep. Disable prefetch so
+                // neither side waits on shared slots, but keep memory 
admission.
+                parquet_read_budget,
                 mosaic_prefetch: configured_mosaic_prefetch(self.table)?,
             },
         );
diff --git a/crates/paimon/tests/reader_resources_test.rs 
b/crates/paimon/tests/reader_resources_test.rs
new file mode 100644
index 00000000..db4a7f88
--- /dev/null
+++ b/crates/paimon/tests/reader_resources_test.rs
@@ -0,0 +1,332 @@
+// 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 std::sync::Arc;
+
+use arrow_array::Int32Array;
+use common::incremental_helpers::{
+    make_batch, memory_table, persist_table_schema, setup_dirs, write_batch,
+};
+use futures::{StreamExt, TryStreamExt};
+use paimon::resource::ResourceContext;
+use paimon::spec::{DataType, Datum, IntType, PredicateBuilder, Schema, 
TableSchema};
+use paimon::table::{ArrowRecordBatchStream, AuditLogRead, IncrementalScanMode, 
Table};
+use paimon::Error;
+
+async fn parquet_table(primary_key: bool) -> Table {
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("file.format", "parquet");
+    if primary_key {
+        schema = schema.primary_key(["id"]).option("bucket", "1");
+    }
+    let path = "memory:/reader_resources";
+    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;
+    write_batch(&table, &make_batch(vec![1, 2, 3], vec![10, 20, 30])).await;
+    table
+}
+
+#[tokio::test]
+async fn parquet_reader_releases_working_memory_while_output_survives() {
+    for primary_key in [false, true] {
+        let table = parquet_table(primary_key).await;
+        let resources = ResourceContext::builder()
+            .memory_limit(1024 * 1024)
+            .build()
+            .unwrap();
+        let mut builder = table.new_read_builder();
+        builder.with_resources(resources.clone());
+        builder.with_projection(&["id"]).unwrap();
+        let plan = builder.new_scan().plan().await.unwrap();
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_or_equal("id", Datum::Int(2))
+            .unwrap();
+        // Builder cloning and subsequent consuming setters must preserve the 
context.
+        let read = builder
+            .clone()
+            .new_read()
+            .unwrap()
+            .with_filter(predicate)
+            .with_blob_parallelism(2)
+            .unwrap()
+            .with_parquet_read_budget(Arc::new(
+                paimon::arrow::ReadBudget::new(2, 1024 * 1024).unwrap(),
+            ));
+        let mut stream = read.to_arrow(plan.splits()).unwrap();
+        let batch = stream.next().await.unwrap().unwrap();
+        let ids = batch
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        assert_eq!(ids.values().as_ref(), &[2, 3]);
+        let bytes = resources.metrics().reserved_memory_bytes;
+        assert!(bytes > 0);
+        let escaped = batch.column(0).slice(1, 1);
+        drop(batch);
+        drop(stream);
+        drop(read);
+        drop(builder);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        assert_eq!(
+            escaped
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .value(0),
+            3
+        );
+        drop(escaped);
+        assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    }
+}
+
+async fn assert_exhausted(mut stream: ArrowRecordBatchStream, resources: 
&ResourceContext) {
+    assert!(matches!(
+        stream.next().await.unwrap(),
+        Err(Error::ResourceExhausted { .. })
+    ));
+    assert!(
+        stream.next().await.is_none(),
+        "exhaustion must end the stream"
+    );
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+}
+
+#[tokio::test]
+async fn every_read_output_mode_honors_the_budget() {
+    let table = parquet_table(false).await;
+    let resources = 
ResourceContext::builder().memory_limit(0).build().unwrap();
+    let mut builder = table.new_read_builder();
+    builder.with_resources(resources.clone());
+    let plan = builder.new_scan().plan().await.unwrap();
+    let incremental = builder
+        .new_incremental_scan(IncrementalScanMode::Delta, 0, 1)
+        .plan()
+        .await
+        .unwrap();
+    let read = builder.new_read().unwrap();
+    assert_exhausted(read.to_arrow(plan.splits()).unwrap(), &resources).await;
+    assert_exhausted(
+        read.to_arrow_with_row_kind(plan.splits()).unwrap(),
+        &resources,
+    )
+    .await;
+    assert_exhausted(read.to_incremental_arrow(&incremental).unwrap(), 
&resources).await;
+    assert_exhausted(read.to_audit_log_arrow(&incremental).unwrap(), 
&resources).await;
+    assert_exhausted(
+        AuditLogRead::new(read)
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap(),
+        &resources,
+    )
+    .await;
+    // No buffers are needed for a zero-column projection, even under a zero 
budget.
+    builder.with_projection(&[]).unwrap();
+    let batches: Vec<_> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(
+        batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+        3
+    );
+    assert!(batches.iter().all(|batch| batch.num_columns() == 0));
+}
+
+#[tokio::test]
+async fn readers_and_downstream_consumers_share_explicit_reservations() {
+    let table = parquet_table(false).await;
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let resources = ResourceContext::builder()
+        .memory_limit(1024 * 1024)
+        .build()
+        .unwrap();
+    let read = table
+        .new_read_builder()
+        .new_read()
+        .unwrap()
+        .with_resources(resources.clone());
+    let mut stream = read.to_arrow(plan.splits()).unwrap();
+    let retained = stream.next().await.unwrap().unwrap();
+    drop(stream);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    // The downstream consumer explicitly accounts for batches it retains.
+    let retained_bytes = retained.get_array_memory_size();
+    let mut downstream = resources.reservation();
+    downstream.try_grow(retained_bytes).unwrap();
+    // Occupy the rest so another reader cannot start its row-group data read.
+    let mut other_consumer = resources.reservation();
+    other_consumer
+        .try_grow(1024 * 1024 - retained_bytes)
+        .unwrap();
+    let mut second = read.clone().to_arrow(plan.splits()).unwrap();
+    assert!(matches!(
+        second.next().await.unwrap(),
+        Err(Error::ResourceExhausted { .. })
+    ));
+    assert!(second.next().await.is_none());
+    drop(second);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 1024 * 1024);
+    drop(other_consumer);
+    drop(retained);
+    drop(downstream);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    // A fresh read can use capacity released by the failed read's siblings.
+    let batches: Vec<_> = read
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(
+        batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+        3
+    );
+    drop(batches);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+}
+
+#[tokio::test]
+async fn format_table_reader_uses_the_same_resource_context() {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("value", DataType::Int(IntType::new()))
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    let path = "memory:/format_reader_resources";
+    let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+    io.mkdirs(path).await.unwrap();
+    let input = make_batch(vec![1, 2], vec![10, 20]);
+    let mut bytes = Vec::new();
+    let mut writer =
+        parquet::arrow::ArrowWriter::try_new(&mut bytes, input.schema(), 
None).unwrap();
+    writer.write(&input).unwrap();
+    writer.close().unwrap();
+    io.new_output(&format!("{path}/data.parquet"))
+        .unwrap()
+        .write(bytes.into())
+        .await
+        .unwrap();
+    let resources = 
ResourceContext::builder().memory_limit(0).build().unwrap();
+    let mut builder = table.new_read_builder();
+    builder.with_resources(resources.clone());
+    let plan = builder.new_scan().plan().await.unwrap();
+    let read = builder
+        .clone()
+        .new_read()
+        .unwrap()
+        .with_blob_parallelism(1)
+        .unwrap();
+    assert_exhausted(read.to_arrow(plan.splits()).unwrap(), &resources).await;
+    assert_exhausted(
+        read.to_arrow_with_row_kind(plan.splits()).unwrap(),
+        &resources,
+    )
+    .await;
+
+    let resources = ResourceContext::builder()
+        .memory_limit(1024)
+        .build()
+        .unwrap();
+    let output: Vec<_> = read
+        .with_resources(resources.clone())
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    assert_eq!(output.len(), 1);
+    assert_eq!(output[0].column(0).as_ref(), input.column(0).as_ref());
+    assert!(resources.metrics().peak_reserved_memory_bytes > 0);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+    drop(output);
+    assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+}
+
+#[tokio::test]
+async fn primary_key_diff_and_audit_diff_share_working_memory() {
+    let table = parquet_table(true).await;
+    write_batch(&table, &make_batch(vec![2], vec![200])).await;
+    for limit in [0, 1024 * 1024] {
+        let resources = ResourceContext::builder()
+            .memory_limit(limit)
+            .build()
+            .unwrap();
+        let mut builder = table.new_read_builder();
+        builder.with_resources(resources.clone());
+        let plan = builder
+            .new_incremental_scan(IncrementalScanMode::Diff, 1, 2)
+            .plan()
+            .await
+            .unwrap();
+        let read = builder.new_read().unwrap();
+        for audit in [false, true] {
+            let stream = if audit {
+                read.to_audit_log_arrow(&plan).unwrap()
+            } else {
+                read.to_incremental_arrow(&plan).unwrap()
+            };
+            let result = tokio::time::timeout(
+                std::time::Duration::from_secs(5),
+                stream.try_collect::<Vec<_>>(),
+            )
+            .await
+            .expect("before/after streams must not deadlock on shared 
capacity");
+            if limit == 0 {
+                assert!(matches!(result, Err(Error::ResourceExhausted { .. 
})));
+            } else {
+                let batches = result.unwrap();
+                let mut values = Vec::new();
+                for batch in &batches {
+                    let ids = batch
+                        .column_by_name("id")
+                        .unwrap()
+                        .as_any()
+                        .downcast_ref::<Int32Array>()
+                        .unwrap();
+                    assert!(ids.values().iter().all(|id| *id == 2));
+                    values.extend_from_slice(
+                        batch
+                            .column_by_name("value")
+                            .unwrap()
+                            .as_any()
+                            .downcast_ref::<Int32Array>()
+                            .unwrap()
+                            .values(),
+                    );
+                }
+                values.sort_unstable();
+                assert_eq!(values, if audit { vec![20, 200] } else { vec![200] 
});
+                assert!(resources.metrics().peak_reserved_memory_bytes > 0);
+            }
+            assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+        }
+    }
+}

Reply via email to