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 05fc0c67 [core] Add bounded parallel table read execution (#1183)
05fc0c67 is described below
commit 05fc0c6781a1bc6bd3aaa77dfacf8ac8e1070047
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 17:18:10 2026 +0800
[core] Add bounded parallel table read execution (#1183)
---
bindings/python/README.md | 11 +
.../python/python/pypaimon_rust/datafusion.pyi | 17 +-
bindings/python/src/read.rs | 125 ++++-
bindings/python/tests/test_read.py | 101 ++++
crates/paimon/src/table/audit_log_table/read.rs | 2 +-
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/read_execution.rs | 619 +++++++++++++++++++++
crates/paimon/src/table/table_read.rs | 328 +++++++++--
crates/paimon/tests/parallel_table_read_test.rs | 589 ++++++++++++++++++++
crates/paimon/tests/rest_catalog_test.rs | 103 ++++
10 files changed, 1824 insertions(+), 72 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index adebdc1e..4e5ae99b 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -132,6 +132,17 @@ scan = read_builder.new_scan()
plan = scan.plan()
batches = read_builder.new_read().read(plan.splits())
+# Core schedules a bounded set of split readers and owns their global limit.
+# Set ordered=True to retain the supplied split order. The default emits
+# whichever split group is ready first. Ordered parallel reads disable
background
+# Parquet row-group prefetch; split workers still read concurrently.
+reader = read_builder.new_read().read_arrow(plan.splits(), parallelism=4,
ordered=False)
+try:
+ for batch in reader:
+ print(batch)
+finally:
+ reader.close()
+
print(f"\nRead: {batches[0].num_rows} rows")
print(batches[0])
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index badf0a44..c5b66362 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -209,10 +209,21 @@ class RecordBatchReader:
...
class TableRead:
- def read_arrow(self, splits: Sequence[Split]) -> RecordBatchReader:
- """Lazily read splits as PyArrow RecordBatches."""
+ def read_arrow(self, splits: Sequence[Split], *, parallelism: int = 1,
+ ordered: bool = False) -> RecordBatchReader:
+ """Lazily read splits as PyArrow RecordBatches.
+
+ parallelism must be positive and bounds concurrent split groups.
+ ordered=True emits groups in the supplied split order; the default
+ emits ready groups first. The row limit applies across the whole read.
+ Ordered parallel reads disable background Parquet row-group prefetch.
+ close() cancels all pending groups.
+ """
+ ...
+ def read(self, splits: Sequence[Split], *, parallelism: int = 1,
+ ordered: bool = False) -> List[pyarrow.RecordBatch]:
+ """Collect the same stream returned by read_arrow()."""
...
- def read(self, splits: Sequence[Split]) -> List[pyarrow.RecordBatch]: ...
class ReadBuilder:
def with_read_type(self, read_type_json: str) -> "ReadBuilder":
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index dbe21716..ed7759f3 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -904,6 +904,8 @@ impl PyTableRead {
&self,
py: Python<'_>,
splits: &Bound<'_, PyAny>,
+ parallelism: usize,
+ ordered: bool,
) -> PyResult<ArrowRecordBatchStream> {
let splits = extract_splits(splits)?;
py.detach(|| {
@@ -926,7 +928,12 @@ impl PyTableRead {
// Validate config (e.g. projection) before the empty-splits fast
// path so an invalid projection fails consistently regardless of
// how many splits are passed.
- let read = builder.new_read().map_err(to_py_err)?;
+ let read = builder
+ .new_read()
+ .map_err(to_py_err)?
+ .with_parallelism(parallelism)
+ .map_err(to_py_err)?
+ .with_ordered(ordered);
let splits = runtime()
.block_on(read.authorize_splits(&splits))
.map_err(to_py_err)?;
@@ -941,20 +948,35 @@ impl PyTableRead {
#[pymethods]
impl PyTableRead {
- /// Lazily read the given splits as an iterator of PyArrow RecordBatches.
+ /// Lazily read the given splits as PyArrow RecordBatches.
+ /// Core owns split scheduling, the global row limit and cancellation.
+ /// ordered=True retains the supplied split order; the default emits ready
groups first.
+ #[pyo3(signature = (splits, *, parallelism = 1, ordered = false))]
fn read_arrow(
&self,
py: Python<'_>,
splits: &Bound<'_, PyAny>,
+ parallelism: usize,
+ ordered: bool,
) -> PyResult<PyRecordBatchReader> {
- Ok(PyRecordBatchReader::from_stream(
- self.read_stream(py, splits)?,
- ))
+ Ok(PyRecordBatchReader::from_stream(self.read_stream(
+ py,
+ splits,
+ parallelism,
+ ordered,
+ )?))
}
/// Read the given splits into a list of PyArrow RecordBatches.
- fn read(&self, py: Python<'_>, splits: &Bound<'_, PyAny>) ->
PyResult<Vec<Py<PyAny>>> {
- let stream = self.read_stream(py, splits)?;
+ #[pyo3(signature = (splits, *, parallelism = 1, ordered = false))]
+ fn read(
+ &self,
+ py: Python<'_>,
+ splits: &Bound<'_, PyAny>,
+ parallelism: usize,
+ ordered: bool,
+ ) -> PyResult<Vec<Py<PyAny>>> {
+ let stream = self.read_stream(py, splits, parallelism, ordered)?;
let rt = runtime();
let batches = py.detach(|| {
rt.block_on(stream.try_collect::<Vec<_>>())
@@ -1181,8 +1203,97 @@ mod tests {
use std::task::Poll;
use std::time::Duration;
+ use paimon::catalog::Identifier;
+ use paimon::io::FileIOBuilder;
+ use paimon::spec::{IntType, Schema, TableSchema};
+ use pyo3::types::PyList;
+
use super::*;
+ #[test]
+ fn table_read_execution_keywords_validate_before_empty_splits() {
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "read_execution_keywords"),
+ "memory:/read-execution-keywords".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ let read = Py::new(py,
PyReadBuilder::new(table).new_read()).unwrap();
+ for method in ["read", "read_arrow"] {
+ for ordered in [false, true] {
+ let kwargs = PyDict::new(py);
+ kwargs.set_item("parallelism", 3).unwrap();
+ kwargs.set_item("ordered", ordered).unwrap();
+ let result = read
+ .bind(py)
+ .call_method(method, (PyList::empty(py),),
Some(&kwargs))
+ .unwrap();
+ if method == "read" {
+ assert_eq!(result.len().unwrap(), 0);
+ } else {
+ let reader: PyRef<PyRecordBatchReader> =
result.extract().unwrap();
+ assert!(reader.next_record_batch().unwrap().is_none());
+ reader.close_reader();
+ reader.close_reader();
+ }
+
+ kwargs.set_item("parallelism", 0).unwrap();
+ let error = read
+ .bind(py)
+ .call_method(method, (PyList::empty(py),),
Some(&kwargs))
+ .unwrap_err();
+ assert!(error.is_instance_of::<PyValueError>(py));
+ assert!(error.to_string().contains("parallelism"));
+ }
+
+ let signature = py
+ .import("inspect")
+ .unwrap()
+ .getattr("signature")
+ .unwrap()
+ .call1((read.bind(py).getattr(method).unwrap(),))
+ .unwrap();
+ let ordered = signature
+ .getattr("parameters")
+ .unwrap()
+ .get_item("ordered")
+ .unwrap();
+ assert!(!ordered
+ .getattr("default")
+ .unwrap()
+ .extract::<bool>()
+ .unwrap());
+ assert_eq!(
+ ordered
+ .getattr("kind")
+ .unwrap()
+ .getattr("name")
+ .unwrap()
+ .extract::<String>()
+ .unwrap(),
+ "KEYWORD_ONLY"
+ );
+
+ // Keep the existing no-keyword entry point and reject
positional
+ // execution options, which would be ambiguous in future APIs.
+ read.bind(py)
+ .call_method1(method, (PyList::empty(py),))
+ .unwrap();
+ let error = read
+ .bind(py)
+ .call_method1(method, (PyList::empty(py), 3))
+ .unwrap_err();
+ assert!(error.is_instance_of::<PyTypeError>(py));
+ }
+ });
+ }
+
#[test]
fn record_batch_reader_releases_stream_on_completion_or_error() {
for fail in [false, true] {
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 4e7d199d..4b56326e 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1202,6 +1202,79 @@ def
test_read_arrow_close_is_idempotent_and_stops_iteration():
reader.close()
[email protected]("method", ["read", "read_arrow"])
[email protected]("ordered", [False, True])
+def test_parallel_read_preserves_rows_and_requested_split_order(method,
ordered):
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ builder = table.new_read_builder().with_projection(["id", "dt"])
+ splits = list(reversed(builder.new_scan().plan().splits()))
+ assert len(splits) >= 3
+ read = builder.new_read()
+ expected = [row for split in splits for batch in read.read([split])
+ for row in batch.to_pylist()]
+ # Generators follow the same iterable contract as the original reader.
+ batches = getattr(read, method)((split for split in splits),
parallelism=3, ordered=ordered)
+ actual = [row for batch in batches for row in batch.to_pylist()]
+ assert sorted(actual, key=lambda row: row["id"]) == sorted(expected,
key=lambda row: row["id"])
+ if ordered:
+ assert actual == expected
+
+
[email protected]("method", ["read", "read_arrow"])
[email protected]("ordered", [False, True])
[email protected]("limit", [0, 1, 3])
+def test_parallel_read_shares_filter_and_limit_across_splits(method, ordered,
limit):
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ splits = table.new_read_builder().new_scan().plan().splits()
+ builder = (table.new_read_builder().with_projection(["id"])
+ .with_filter({"method": "greaterThan", "field": "id",
"literals": [1]}))
+ read = builder.new_read()
+ expected = [row for split in splits for batch in read.read([split])
+ for row in batch.to_pylist()]
+ limited = builder.with_limit(limit).new_read()
+ batches = getattr(limited, method)(splits, parallelism=3,
ordered=ordered)
+ actual = [row for batch in batches for row in batch.to_pylist()]
+ assert len(actual) == min(limit, len(expected))
+ assert all(row in expected for row in actual)
+ assert len({row["id"] for row in actual}) == len(actual)
+ if ordered:
+ assert actual == expected[:limit]
+
+
[email protected]("method", ["read", "read_arrow"])
+def test_parallel_read_keeps_zero_column_row_counts(method):
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ splits = table.new_read_builder().new_scan().plan().splits()
+ read =
table.new_read_builder().with_projection([]).with_limit(3).new_read()
+ batches = list(getattr(read, method)(splits, parallelism=3,
ordered=True))
+ assert sum(batch.num_rows for batch in batches) == 3
+ assert all(batch.num_columns == 0 for batch in batches)
+
+
[email protected]("method", ["read", "read_arrow"])
+def test_parallel_read_rejects_zero_parallelism_before_empty_splits(method):
+ with tempfile.TemporaryDirectory() as warehouse:
+ read = _make_table_with_data(warehouse).new_read_builder().new_read()
+ with pytest.raises(ValueError, match="parallelism"):
+ getattr(read, method)([], parallelism=0)
+
+
+def test_parallel_read_arrow_close_after_first_batch_stops_iteration():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ builder = table.new_read_builder()
+ splits = builder.new_scan().plan().splits()
+ reader = builder.new_read().read_arrow(splits, parallelism=3)
+ assert next(reader).num_rows > 0
+ reader.close()
+ assert reader.read_next_batch() is None
+ assert list(reader) == []
+ reader.close()
+
+
def test_read_empty_splits():
with tempfile.TemporaryDirectory() as warehouse:
table = _make_table_with_data(warehouse)
@@ -1626,6 +1699,34 @@ def
test_incremental_changelog_and_auto_plan_physical_changelog_files():
assert empty.splits() == []
[email protected]("method", ["read", "read_arrow"])
[email protected]("ordered", [False, True])
+def test_parallel_read_keeps_streaming_row_kinds(method, ordered):
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.parallel_changes")
+ ctx.sql("""CREATE TABLE paimon.parallel_changes.t (
+ dt STRING, id INT, value STRING, PRIMARY KEY (dt, id)
+ ) PARTITIONED BY (dt) WITH ('bucket' = '1', 'changelog-producer' =
'input')""")
+ ctx.sql("INSERT INTO paimon.parallel_changes.t VALUES ('p1', 1, 'a'),
('p2', 2, 'b')")
+ ctx.sql("INSERT INTO paimon.parallel_changes.t VALUES ('p3', 3, 'c')")
+ table = PaimonCatalog({"warehouse":
warehouse}).get_table("parallel_changes.t")
+ builder =
table.new_read_builder().with_projection(["id"]).with_include_row_kind(True)
+ splits = list(reversed(builder.new_incremental_scan(0, 2,
"changelog").plan().splits()))
+ assert len(splits) == 3
+ assert all(split.is_streaming() for split in splits)
+ read = builder.new_read()
+ expected = [row for split in splits for batch in read.read([split])
+ for row in batch.to_pylist()]
+ batches = getattr(read, method)(splits, parallelism=3, ordered=ordered)
+ actual = [row for batch in batches for row in batch.to_pylist()]
+ assert sorted(actual, key=lambda row: row["id"]) == [
+ {"rowkind": "+I", "id": 1}, {"rowkind": "+I", "id": 2},
{"rowkind": "+I", "id": 3}]
+ if ordered:
+ assert actual == expected
+
+
@pytest.mark.parametrize("mode", ["changelog", "auto"])
def test_batch_incremental_changelog_packing_sharding_and_limits(mode):
with tempfile.TemporaryDirectory() as warehouse:
diff --git a/crates/paimon/src/table/audit_log_table/read.rs
b/crates/paimon/src/table/audit_log_table/read.rs
index c5e55fc3..4719b39b 100644
--- a/crates/paimon/src/table/audit_log_table/read.rs
+++ b/crates/paimon/src/table/audit_log_table/read.rs
@@ -46,7 +46,7 @@ impl<'a> AuditLogRead<'a> {
// Query-auth is decided per split in `to_arrow`; only the type is
known here.
crate::spec::CoreOptions::new(read.table().schema().options())
.ensure_type_paimon_served(&read.table().identifier().full_name())?;
- match read.0 {
+ match read.kind {
TableReadKind::Paimon(read) => Ok(Self { read }),
TableReadKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support audit log batch
read".to_string(),
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index e811ca07..a8b35eb1 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -139,6 +139,7 @@ pub use system_table::SystemRead;
mod key_normalization;
mod read_builder;
+mod read_execution;
mod read_limit;
pub mod referenced_files;
pub(crate) mod rest_env;
diff --git a/crates/paimon/src/table/read_execution.rs
b/crates/paimon/src/table/read_execution.rs
new file mode 100644
index 00000000..b7a4ba3b
--- /dev/null
+++ b/crates/paimon/src/table/read_execution.rs
@@ -0,0 +1,619 @@
+// 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.
+
+//! Bounded split execution shared by table readers and language bindings.
+
+use super::{read_limit::take_limited_batch, ArrowRecordBatchStream};
+use crate::DataSplit;
+use arrow_array::RecordBatch;
+use futures::future::BoxFuture;
+use futures::{FutureExt, StreamExt};
+use std::collections::BTreeMap;
+use std::sync::Arc;
+use tokio::task::JoinSet;
+
+pub(super) type SplitReader =
+ Arc<dyn Fn(&[DataSplit], Option<usize>) ->
crate::Result<ArrowRecordBatchStream> + Send + Sync>;
+
+type NextBatch = (
+ usize,
+ Option<crate::Result<RecordBatch>>,
+ ArrowRecordBatchStream,
+);
+
+/// A contiguous minimax partition balances file bytes without changing split
order.
+fn split_groups(splits: Vec<DataSplit>, parallelism: usize) ->
Vec<Vec<DataSplit>> {
+ let weights = splits
+ .iter()
+ .map(|split| {
+ split
+ .data_files()
+ .iter()
+ .fold(0_u64, |sum, file| {
+
sum.saturating_add(u64::try_from(file.file_size).unwrap_or(0))
+ })
+ .max(1)
+ })
+ .collect::<Vec<_>>();
+ let ranges = group_ranges(&weights, parallelism);
+ let mut splits = splits.into_iter();
+ ranges
+ .into_iter()
+ .map(|(start, end)| splits.by_ref().take(end - start).collect())
+ .collect()
+}
+
+fn group_ranges(weights: &[u64], parallelism: usize) -> Vec<(usize, usize)> {
+ let workers = parallelism.min(weights.len());
+ if workers == 0 {
+ return Vec::new();
+ }
+ let mut low = *weights.iter().max().unwrap();
+ let mut high = weights.iter().copied().fold(0_u64, u64::saturating_add);
+ while low < high {
+ let capacity = low + (high - low) / 2;
+ let mut needed = 1;
+ let mut current = 0_u64;
+ for &weight in weights {
+ if current.saturating_add(weight) > capacity {
+ needed += 1;
+ current = weight;
+ } else {
+ current = current.saturating_add(weight);
+ }
+ }
+ if needed <= workers {
+ high = capacity;
+ } else {
+ low = capacity + 1;
+ }
+ }
+ let mut ranges = Vec::with_capacity(workers);
+ let mut start = 0;
+ for worker in 0..workers {
+ let left = workers - worker;
+ if left == 1 {
+ ranges.push((start, weights.len()));
+ break;
+ }
+ let mut end = start;
+ let mut weight = 0_u64;
+ while end < weights.len() - (left - 1) {
+ let next = weight.saturating_add(weights[end]);
+ if end > start && next > low {
+ break;
+ }
+ weight = next;
+ end += 1;
+ }
+ ranges.push((start, end));
+ start = end;
+ }
+ ranges
+}
+
+pub(super) fn execute(
+ splits: Vec<DataSplit>,
+ parallelism: usize,
+ ordered: bool,
+ limit: Option<usize>,
+ read: SplitReader,
+) -> ArrowRecordBatchStream {
+ let streams = split_groups(splits, parallelism)
+ .into_iter()
+ .map(|group| {
+ let read = Arc::clone(&read);
+ Box::pin(async_stream::try_stream! {
+ let mut remaining = limit;
+ for split in group {
+ if remaining == Some(0) { break; }
+ // Open later splits only on demand, with the remaining
quota.
+ let mut stream = read(std::slice::from_ref(&split),
remaining)?;
+ while remaining != Some(0) {
+ let Some(batch) = stream.next().await else { break; };
+ let batch = batch?;
+ if batch.num_rows() == 0 { continue; }
+ yield take_limited_batch(batch, &mut remaining);
+ }
+ }
+ }) as ArrowRecordBatchStream
+ })
+ .collect();
+ merge_streams(streams, ordered, limit)
+}
+
+fn next_batch(index: usize, mut stream: ArrowRecordBatchStream) ->
BoxFuture<'static, NextBatch> {
+ async move {
+ let batch = stream.next().await;
+ (index, batch, stream)
+ }
+ .boxed()
+}
+
+/// Every group has one pending read or one ready batch. Multiple groups run
+/// on owned runtime tasks so synchronous decoder work can use separate CPUs.
+/// Dropping the output aborts every task through JoinSet's drop
implementation.
+fn merge_streams(
+ mut streams: Vec<ArrowRecordBatchStream>,
+ ordered: bool,
+ limit: Option<usize>,
+) -> ArrowRecordBatchStream {
+ if limit == Some(0) {
+ return Box::pin(futures::stream::empty());
+ }
+ // Ordinary one-worker reads retain the original executor context and avoid
+ // requiring a Tokio runtime just to advance a stream.
+ if streams.len() <= 1 {
+ return limit_single_stream(
+ streams
+ .pop()
+ .unwrap_or_else(|| Box::pin(futures::stream::empty())),
+ limit,
+ );
+ }
+ Box::pin(async_stream::try_stream! {
+ let handle = match tokio::runtime::Handle::try_current() {
+ Ok(handle) => handle,
+ Err(error) => {
+ drop(streams);
+ Err(crate::Error::UnexpectedError {
+ message: "Parallel table reads require a Tokio
runtime".into(),
+ source: Some(Box::new(error)),
+ })?;
+ return;
+ }
+ };
+ let count = streams.len();
+ let mut pending = JoinSet::new();
+ for (index, stream) in streams.into_iter().enumerate() {
+ pending.spawn_on(next_batch(index, stream), &handle);
+ }
+ let mut ready = BTreeMap::new();
+ let mut current = 0;
+ let mut remaining = limit;
+ while remaining != Some(0) && current < count {
+ let next = if ordered {
+ loop {
+ if let Some(next) = ready.remove(¤t) { break
Ok(Some(next)); }
+ let next = match pending.join_next().await {
+ Some(Ok(next)) => next,
+ Some(Err(error)) => break Err(worker_error(error)),
+ None => break Ok(None),
+ };
+ // Surface a failed worker even while an earlier group is
+ // blocked; cleanup happens before exposing the error.
+ if next.0 == current ||
next.1.as_ref().is_some_and(Result::is_err) {
+ break Ok(Some(next));
+ }
+ ready.insert(next.0, next);
+ }
+ } else {
+ pending.join_next().await.transpose().map_err(worker_error)
+ };
+ let (index, batch, stream) = match next {
+ Ok(Some(next)) => next,
+ Ok(None) => break,
+ Err(error) => {
+ drop(ready);
+ pending.shutdown().await;
+ Err(error)?;
+ break;
+ }
+ };
+ match batch {
+ Some(Err(error)) => {
+ // async_stream's `?` yields before returning. Release
every
+ // worker first, even if the caller retains the errored
stream.
+ drop(stream);
+ drop(ready);
+ pending.shutdown().await;
+ Err(error)?;
+ break;
+ }
+ Some(Ok(batch)) => {
+ if batch.num_rows() > 0 {
+ let batch = take_limited_batch(batch, &mut remaining);
+ if remaining == Some(0) {
+ // Reaching the quota cancels unused work before
the
+ // final batch is handed to the caller.
+ drop(stream);
+ drop(ready);
+ pending.shutdown().await;
+ yield batch;
+ break;
+ }
+ yield batch;
+ }
+ // Schedule another decode only after the caller resumes.
+ pending.spawn_on(next_batch(index, stream), &handle);
+ }
+ None if ordered => current += 1,
+ None => {},
+ }
+ }
+ })
+}
+
+fn worker_error(error: tokio::task::JoinError) -> crate::Error {
+ crate::Error::UnexpectedError {
+ message: format!("Table read worker failed: {error}"),
+ source: Some(Box::new(error)),
+ }
+}
+
+fn limit_single_stream(
+ mut stream: ArrowRecordBatchStream,
+ limit: Option<usize>,
+) -> ArrowRecordBatchStream {
+ Box::pin(async_stream::try_stream! {
+ let mut remaining = limit;
+ while remaining != Some(0) {
+ let Some(batch) = stream.next().await else { break; };
+ match batch {
+ Err(error) => {
+ drop(stream);
+ Err(error)?;
+ break;
+ }
+ Ok(batch) => {
+ if batch.num_rows() == 0 { continue; }
+ let batch = take_limited_batch(batch, &mut remaining);
+ if remaining == Some(0) {
+ drop(stream);
+ yield batch;
+ break;
+ }
+ yield batch;
+ }
+ }
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::Int32Array;
+ use futures::channel::oneshot;
+ use futures::TryStreamExt;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::time::Duration;
+
+ async fn wait_for_count(count: &AtomicUsize, expected: usize) {
+ tokio::time::timeout(Duration::from_secs(5), async {
+ while count.load(Ordering::SeqCst) != expected {
+ tokio::task::yield_now().await;
+ }
+ })
+ .await
+ .expect("worker did not reach its expected lifecycle state");
+ }
+
+ fn batch(value: i32) -> RecordBatch {
+ RecordBatch::try_from_iter([(
+ "id",
+ Arc::new(Int32Array::from(vec![value])) as arrow_array::ArrayRef,
+ )])
+ .unwrap()
+ }
+
+ fn value(batch: &RecordBatch) -> i32 {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0)
+ }
+
+ struct DropGuard(Arc<AtomicUsize>);
+ impl Drop for DropGuard {
+ fn drop(&mut self) {
+ self.0.fetch_add(1, Ordering::SeqCst);
+ }
+ }
+
+ fn controlled_stream(
+ values: Vec<i32>,
+ first: Option<oneshot::Receiver<()>>,
+ reads: Arc<AtomicUsize>,
+ drops: Arc<AtomicUsize>,
+ ) -> ArrowRecordBatchStream {
+ let guard = DropGuard(drops);
+ Box::pin(async_stream::try_stream! {
+ let _guard = guard;
+ if let Some(first) = first { let _ = first.await; }
+ for item in values {
+ reads.fetch_add(1, Ordering::SeqCst);
+ yield batch(item);
+ }
+ })
+ }
+
+ #[test]
+ fn contiguous_groups_balance_skewed_files_without_reordering() {
+ assert_eq!(
+ group_ranges(&[100, 1, 1, 1, 1, 1], 3),
+ vec![(0, 1), (1, 5), (5, 6)]
+ );
+ assert_eq!(group_ranges(&[1, 1, 1, 1], 2), vec![(0, 2), (2, 4)]);
+ assert_eq!(group_ranges(&[1, 1], 8), vec![(0, 1), (1, 2)]);
+ assert!(group_ranges(&[], 2).is_empty());
+ }
+
+ #[tokio::test]
+ async fn ordered_execution_prefetches_one_batch_per_group() {
+ let (release, wait) = oneshot::channel();
+ let reads = Arc::new(AtomicUsize::new(0));
+ let later_reads = Arc::new(AtomicUsize::new(0));
+ let drops = Arc::new(AtomicUsize::new(0));
+ let mut output = merge_streams(
+ vec![
+ controlled_stream(vec![1, 2], Some(wait), reads.clone(),
drops.clone()),
+ controlled_stream(vec![3, 4], None, later_reads.clone(),
drops.clone()),
+ ],
+ true,
+ None,
+ );
+ assert!(output.next().now_or_never().is_none());
+ wait_for_count(&later_reads, 1).await;
+ assert_eq!(later_reads.load(Ordering::SeqCst), 1);
+ release.send(()).unwrap();
+ assert_eq!(value(&output.next().await.unwrap().unwrap()), 1);
+ assert_eq!(later_reads.load(Ordering::SeqCst), 1);
+ let rest = output
+ .map_ok(|batch| value(&batch))
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(rest, vec![2, 3, 4]);
+ assert_eq!(reads.load(Ordering::SeqCst), 2);
+ assert_eq!(drops.load(Ordering::SeqCst), 2);
+ }
+
+ #[tokio::test]
+ async fn
unordered_execution_yields_ready_groups_and_close_cancels_pending_reads() {
+ let (_release, wait) = oneshot::channel();
+ let reads = Arc::new(AtomicUsize::new(0));
+ let later_reads = Arc::new(AtomicUsize::new(0));
+ let drops = Arc::new(AtomicUsize::new(0));
+ let mut output = merge_streams(
+ vec![
+ controlled_stream(vec![1], Some(wait), reads.clone(),
drops.clone()),
+ controlled_stream(vec![2, 3], None, later_reads.clone(),
drops.clone()),
+ ],
+ false,
+ None,
+ );
+ assert_eq!(value(&output.next().await.unwrap().unwrap()), 2);
+ assert_eq!(reads.load(Ordering::SeqCst), 0);
+ assert_eq!(later_reads.load(Ordering::SeqCst), 1);
+ drop(output);
+ wait_for_count(&drops, 2).await;
+ assert_eq!(later_reads.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn worker_errors_cancel_other_readers_even_in_ordered_mode() {
+ let (_release, wait) = oneshot::channel();
+ let reads = Arc::new(AtomicUsize::new(0));
+ let drops = Arc::new(AtomicUsize::new(0));
+ let ready_reads = Arc::new(AtomicUsize::new(0));
+ let ready_before_failure = ready_reads.clone();
+ let failing = Box::pin(futures::stream::once(async move {
+ wait_for_count(&ready_before_failure, 1).await;
+ Err(crate::Error::DataInvalid {
+ message: "worker failed".into(),
+ source: None,
+ })
+ })) as ArrowRecordBatchStream;
+ let mut output = merge_streams(
+ vec![
+ controlled_stream(vec![1], Some(wait), reads, drops.clone()),
+ controlled_stream(vec![2, 3], None, ready_reads.clone(),
drops.clone()),
+ failing,
+ ],
+ true,
+ None,
+ );
+ assert!(output
+ .next()
+ .await
+ .unwrap()
+ .unwrap_err()
+ .to_string()
+ .contains("worker failed"));
+ assert_eq!(ready_reads.load(Ordering::SeqCst), 1);
+ assert_eq!(drops.load(Ordering::SeqCst), 2);
+ // Keeping the failed stream alive must not retain another worker.
+ assert!(output.next().await.is_none());
+ }
+
+ #[tokio::test]
+ async fn global_limit_stops_before_requesting_another_batch() {
+ let reads = (0..3)
+ .map(|_| Arc::new(AtomicUsize::new(0)))
+ .collect::<Vec<_>>();
+ let drops = Arc::new(AtomicUsize::new(0));
+ let streams = reads
+ .iter()
+ .enumerate()
+ .map(|(i, reads)| {
+ controlled_stream(
+ vec![i as i32, i as i32 + 10],
+ None,
+ reads.clone(),
+ drops.clone(),
+ )
+ })
+ .collect();
+ let mut output = merge_streams(streams, false, Some(1));
+ let last = output.next().await.unwrap().unwrap();
+ assert_eq!(last.num_rows(), 1);
+ let started = reads
+ .iter()
+ .map(|count| count.load(Ordering::SeqCst))
+ .collect::<Vec<_>>();
+ assert!(started.iter().all(|count| *count <= 1));
+ assert!((1..=3).contains(&started.iter().sum::<usize>()));
+ assert_eq!(drops.load(Ordering::SeqCst), 3);
+ // The caller may keep the final batch and stream without polling
again.
+ assert!(output.next().await.is_none());
+ assert_eq!(
+ reads
+ .iter()
+ .map(|count| count.load(Ordering::SeqCst))
+ .collect::<Vec<_>>(),
+ started
+ );
+ }
+
+ #[test]
+ fn one_worker_and_empty_reads_do_not_require_a_tokio_runtime() {
+ let reads = Arc::new(AtomicUsize::new(0));
+ let drops = Arc::new(AtomicUsize::new(0));
+ futures::executor::block_on(async {
+ let mut output = merge_streams(
+ vec![controlled_stream(
+ vec![1, 2],
+ None,
+ reads.clone(),
+ drops.clone(),
+ )],
+ false,
+ Some(1),
+ );
+ assert_eq!(value(&output.next().await.unwrap().unwrap()), 1);
+ assert_eq!(reads.load(Ordering::SeqCst), 1);
+ assert_eq!(drops.load(Ordering::SeqCst), 1);
+ assert!(output.next().await.is_none());
+ assert!(merge_streams(Vec::new(), true,
None).next().await.is_none());
+ let guard = DropGuard(drops.clone());
+ let failing = Box::pin(async_stream::stream! {
+ let _guard = guard;
+ yield Err(crate::Error::DataInvalid {
+ message: "serial worker failed".into(), source: None,
+ });
+ std::future::pending::<()>().await;
+ }) as ArrowRecordBatchStream;
+ let mut output = merge_streams(vec![failing], false, None);
+ assert!(output.next().await.unwrap().is_err());
+ assert_eq!(drops.load(Ordering::SeqCst), 2);
+ assert!(output.next().await.is_none());
+ let zero_drops = Arc::new(AtomicUsize::new(0));
+ let streams = (0..2)
+ .map(|i| controlled_stream(vec![i], None, reads.clone(),
zero_drops.clone()))
+ .collect();
+ let mut output = merge_streams(streams, false, Some(0));
+ assert_eq!(zero_drops.load(Ordering::SeqCst), 2);
+ assert!(output.next().await.is_none());
+ let parallel_drops = Arc::new(AtomicUsize::new(0));
+ let streams = (0..2)
+ .map(|i| controlled_stream(vec![i], None, reads.clone(),
parallel_drops.clone()))
+ .collect();
+ let mut output = merge_streams(streams, false, None);
+ let error = output.next().await.unwrap().unwrap_err();
+ assert!(matches!(&error, crate::Error::UnexpectedError { .. }));
+ assert!(error.to_string().contains("Tokio runtime"));
+ assert_eq!(parallel_drops.load(Ordering::SeqCst), 2);
+ assert!(output.next().await.is_none());
+ });
+ }
+
+ #[tokio::test]
+ async fn
a_panicking_worker_cancels_other_workers_before_returning_the_error() {
+ let (_release, wait) = oneshot::channel();
+ let drops = Arc::new(AtomicUsize::new(0));
+ let panicking = Box::pin(futures::stream::once(async {
+ panic!("decoder panic");
+ #[allow(unreachable_code)]
+ Ok(batch(2))
+ })) as ArrowRecordBatchStream;
+ let mut output = merge_streams(
+ vec![
+ controlled_stream(
+ vec![1],
+ Some(wait),
+ Arc::new(AtomicUsize::new(0)),
+ drops.clone(),
+ ),
+ panicking,
+ ],
+ true,
+ None,
+ );
+ let error = output.next().await.unwrap().unwrap_err();
+ assert!(matches!(error, crate::Error::UnexpectedError { .. }));
+ assert_eq!(drops.load(Ordering::SeqCst), 1);
+ assert!(output.next().await.is_none());
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+ async fn
independent_workers_poll_synchronous_decoders_on_separate_threads() {
+ #[derive(Default)]
+ struct State {
+ entered: usize,
+ active: usize,
+ peak: usize,
+ threads: std::collections::HashSet<std::thread::ThreadId>,
+ }
+ for ordered in [false, true] {
+ let shared = Arc::new((
+ std::sync::Mutex::new(State::default()),
+ std::sync::Condvar::new(),
+ ));
+ let streams = (0..2)
+ .map(|value| {
+ let shared = shared.clone();
+ Box::pin(futures::stream::once(async move {
+ // This synchronous portion represents a decoder's CPU
work.
+ // The bounded rendezvous lets the former inline
executor
+ // finish rather than hanging forever: it records
peak=1.
+ let (lock, changed) = &*shared;
+ let mut state = lock.lock().unwrap();
+ state.entered += 1;
+ state.active += 1;
+ state.peak = state.peak.max(state.active);
+ state.threads.insert(std::thread::current().id());
+ changed.notify_all();
+ let deadline = std::time::Instant::now() +
Duration::from_secs(2);
+ while state.entered < 2 {
+ let remaining =
+
deadline.saturating_duration_since(std::time::Instant::now());
+ let (next, timeout) = changed.wait_timeout(state,
remaining).unwrap();
+ state = next;
+ if timeout.timed_out() {
+ break;
+ }
+ }
+ state.active -= 1;
+ drop(state);
+ Ok(batch(value))
+ })) as ArrowRecordBatchStream
+ })
+ .collect();
+ let batches = merge_streams(streams, ordered, None)
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(batches.len(), 2);
+ let state = shared.0.lock().unwrap();
+ assert_eq!(state.peak, 2, "synchronous decoder work was
serialized");
+ assert_eq!(state.threads.len(), 2);
+ }
+ }
+}
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 39efafd6..47d38e5e 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -50,12 +50,17 @@ mod audit;
pub use audit::AuditLogRead;
const MAX_MERGE_INPUT_STREAMS: usize = 256;
+const MAX_TOTAL_BLOB_READS: usize = 64;
/// Table read: reads data from splits (e.g. produced by [TableScan::plan]).
///
/// Reference:
[pypaimon.read.table_read.TableRead](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/read/table_read.py)
#[derive(Debug, Clone)]
-pub struct TableRead<'a>(TableReadKind<'a>);
+pub struct TableRead<'a> {
+ kind: TableReadKind<'a>,
+ parallelism: usize,
+ ordered: bool,
+}
#[derive(Debug, Clone)]
enum TableReadKind<'a> {
@@ -84,7 +89,7 @@ impl<'a> TableRead<'a> {
/// original rows. This does not authorize a read: every split must still
/// carry a whole-table unrestricted grant issued to this exact handle.
pub(super) fn for_update(mut self) -> Self {
- if let TableReadKind::Paimon(read) = &mut self.0 {
+ if let TableReadKind::Paimon(read) = &mut self.kind {
read.update_read = true;
}
self
@@ -99,11 +104,15 @@ impl<'a> TableRead<'a> {
if table.is_format_table() {
Self::new_format(table, read_type, data_predicates, None)
} else {
- Self(TableReadKind::Paimon(PaimonTableRead::new(
- table,
- read_type,
- data_predicates,
- )))
+ Self {
+ kind: TableReadKind::Paimon(PaimonTableRead::new(
+ table,
+ read_type,
+ data_predicates,
+ )),
+ parallelism: 1,
+ ordered: false,
+ }
}
}
@@ -113,17 +122,21 @@ impl<'a> TableRead<'a> {
data_predicates: Vec<Predicate>,
limit: Option<usize>,
) -> Self {
- Self(TableReadKind::Format(FormatTableRead::new(
- table,
- read_type,
- data_predicates,
- limit,
- )))
+ Self {
+ kind: TableReadKind::Format(FormatTableRead::new(
+ table,
+ read_type,
+ data_predicates,
+ limit,
+ )),
+ parallelism: 1,
+ ordered: false,
+ }
}
/// Schema (fields) that this read will produce.
pub fn read_type(&self) -> &[DataField] {
- match &self.0 {
+ match &self.kind {
TableReadKind::Paimon(read) => read.read_type(),
TableReadKind::Format(read) => read.read_type(),
}
@@ -131,7 +144,7 @@ impl<'a> TableRead<'a> {
/// Data predicates for read-side pruning.
pub fn data_predicates(&self) -> &[Predicate] {
- match &self.0 {
+ match &self.kind {
TableReadKind::Paimon(read) => read.data_predicates(),
TableReadKind::Format(read) => read.data_predicates(),
}
@@ -139,7 +152,7 @@ impl<'a> TableRead<'a> {
/// Table for this read.
pub fn table(&self) -> &Table {
- match &self.0 {
+ match &self.kind {
TableReadKind::Paimon(read) => read.table(),
TableReadKind::Format(read) => read.table(),
}
@@ -150,31 +163,68 @@ impl<'a> TableRead<'a> {
/// 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 {
+ match &mut self.kind {
TableReadKind::Paimon(read) => read.resources = Some(resources),
TableReadKind::Format(read) => read.with_resources(resources),
}
self
}
+ /// Read independent splits concurrently, with at most one ready batch per
worker.
+ ///
+ /// Applies to [`Self::to_arrow`] and [`Self::to_arrow_with_row_kind`] on
+ /// Paimon tables. Incremental diff and audit readers keep their own
execution.
+ /// Multiple groups run on Tokio workers; poll parallel reads in a Tokio
runtime.
+ /// The default is one worker. All workers share the same Parquet resource
+ /// budget. Reads that resolve BLOB values before a finite limit remain
+ /// sequential so unused payloads are never fetched speculatively.
+ pub fn with_parallelism(mut self, parallelism: usize) ->
crate::Result<Self> {
+ if parallelism == 0 {
+ return Err(crate::Error::DataInvalid {
+ message: "Read parallelism must be greater than zero".into(),
+ source: None,
+ });
+ }
+ self.parallelism = parallelism;
+ Ok(self)
+ }
+
+ /// Control whether output follows the supplied split order.
+ ///
+ /// When true, batches follow the supplied split order while reads remain
+ /// concurrent. When false (the default), ready split groups can emit
first.
+ /// Rows within a split keep that split reader's existing order in both
modes.
+ /// Ordered parallel reads disable background Parquet row-group prefetch so
+ /// a later group cannot retain scheduling permits needed by an earlier
one.
+ /// Foreground reads still share memory admission from
[`Self::with_resources`].
+ pub fn with_ordered(mut self, ordered: bool) -> Self {
+ self.ordered = ordered;
+ self
+ }
+
+ fn map_kind(mut self, map: impl FnOnce(TableReadKind<'a>) ->
TableReadKind<'a>) -> Self {
+ self.kind = map(self.kind);
+ self
+ }
+
/// Set a filter predicate.
pub fn with_filter(self, filter: Predicate) -> Self {
- match self.0 {
- TableReadKind::Paimon(read) =>
Self(TableReadKind::Paimon(read.with_filter(filter))),
- TableReadKind::Format(read) =>
Self(TableReadKind::Format(read.with_filter(filter))),
- }
+ self.map_kind(|kind| match kind {
+ TableReadKind::Paimon(read) =>
TableReadKind::Paimon(read.with_filter(filter)),
+ TableReadKind::Format(read) =>
TableReadKind::Format(read.with_filter(filter)),
+ })
}
/// Ordinary partition conjuncts are evaluated against split metadata,
/// since partition columns may be absent from physical data files. The
/// predicate uses partition-row indices, not table-field indices.
pub(crate) fn with_partition_predicate(self, predicate: Option<Predicate>)
-> Self {
- match self.0 {
- TableReadKind::Paimon(read) => Self(TableReadKind::Paimon(
- read.with_partition_predicate(predicate),
- )),
- TableReadKind::Format(_) => self,
- }
+ self.map_kind(|kind| match kind {
+ TableReadKind::Paimon(read) => {
+ TableReadKind::Paimon(read.with_partition_predicate(predicate))
+ }
+ other => other,
+ })
}
/// Set the maximum number of concurrent BLOB range reads for this read.
@@ -185,26 +235,26 @@ impl<'a> TableRead<'a> {
source: None,
});
}
- Ok(match self.0 {
- TableReadKind::Paimon(read) => Self(TableReadKind::Paimon(
- read.with_blob_parallelism(blob_parallelism),
- )),
- TableReadKind::Format(read) => Self(TableReadKind::Format(
- read.with_blob_parallelism(blob_parallelism),
- )),
- })
+ Ok(self.map_kind(|kind| match kind {
+ TableReadKind::Paimon(read) => {
+
TableReadKind::Paimon(read.with_blob_parallelism(blob_parallelism))
+ }
+ TableReadKind::Format(read) => {
+
TableReadKind::Format(read.with_blob_parallelism(blob_parallelism))
+ }
+ }))
}
/// Pass the limit to Data Evolution and primary-key readers. These paths
/// apply it after merging/filtering and before output BLOB resolution.
pub(crate) fn with_limit(self, limit: Option<usize>) -> Self {
- match self.0 {
+ self.map_kind(|kind| match kind {
TableReadKind::Paimon(mut read) => {
read.limit = limit;
- Self(TableReadKind::Paimon(read))
+ TableReadKind::Paimon(read)
}
- TableReadKind::Format(read) => Self(TableReadKind::Format(read)),
- }
+ other => other,
+ })
}
/// Attach an engine-specific Parquet decoder-filter factory.
@@ -213,36 +263,36 @@ impl<'a> TableRead<'a> {
/// enforce the expression after the scan because an individual file may
not
/// be able to build a decoder filter. A `query-auth.enabled` read refuses
it.
pub fn with_row_filter_factory(self, factory: Arc<dyn
crate::arrow::RowFilterFactory>) -> Self {
- match self.0 {
+ self.map_kind(|kind| match kind {
TableReadKind::Paimon(read) => {
-
Self(TableReadKind::Paimon(read.with_row_filter_factory(factory)))
+ TableReadKind::Paimon(read.with_row_filter_factory(factory))
}
TableReadKind::Format(read) => {
-
Self(TableReadKind::Format(read.with_row_filter_factory(factory)))
+ TableReadKind::Format(read.with_row_filter_factory(factory))
}
- }
+ })
}
/// Override the Parquet resource budget shared by this read.
#[doc(hidden)]
pub fn with_parquet_read_budget(self, budget: Arc<ReadBudget>) -> Self {
- match self.0 {
+ self.map_kind(|kind| match kind {
TableReadKind::Paimon(read) => {
-
Self(TableReadKind::Paimon(read.with_parquet_read_budget(budget)))
+ TableReadKind::Paimon(read.with_parquet_read_budget(budget))
}
TableReadKind::Format(read) => {
-
Self(TableReadKind::Format(read.with_parquet_read_budget(budget)))
+ TableReadKind::Format(read.with_parquet_read_budget(budget))
}
- }
+ })
}
pub(crate) fn with_data_file_read_timing(self, timing:
Arc<DataFileReadTiming>) -> Self {
- match self.0 {
- TableReadKind::Paimon(read) => Self(TableReadKind::Paimon(
- read.with_data_file_read_timing(timing),
- )),
- TableReadKind::Format(read) => Self(TableReadKind::Format(read)),
- }
+ self.map_kind(|kind| match kind {
+ TableReadKind::Paimon(read) => {
+ TableReadKind::Paimon(read.with_data_file_read_timing(timing))
+ }
+ other => other,
+ })
}
/// Restore catalog authorization for transported or externally planned
splits.
@@ -266,7 +316,7 @@ impl<'a> TableRead<'a> {
.map(|field| field.name().into())
.collect();
select.extend(leaf_names(self.data_predicates()));
- if let TableReadKind::Paimon(read) = &self.0 {
+ if let TableReadKind::Paimon(read) = &self.kind {
if let Some(predicate) = &read.partition_predicate {
select.extend(leaf_names(std::slice::from_ref(predicate)));
}
@@ -321,7 +371,7 @@ impl<'a> TableRead<'a> {
};
restored.push(split.clone().planned(Some(grant)));
}
- let TableReadKind::Paimon(read) = &self.0 else {
+ let TableReadKind::Paimon(read) = &self.kind else {
return Err(unsupported(
"only Paimon data splits carry query authorization",
));
@@ -357,8 +407,10 @@ impl<'a> TableRead<'a> {
/// A `query-auth.enabled` table reads only splits from this handle's plan;
/// re-plan after a permission change.
pub fn to_arrow(&self, data_splits: &[DataSplit]) ->
crate::Result<ArrowRecordBatchStream> {
- match &self.0 {
- TableReadKind::Paimon(read) => read.to_arrow(data_splits),
+ match &self.kind {
+ TableReadKind::Paimon(read) => {
+ read.execute_splits(data_splits, self.parallelism,
self.ordered, false)
+ }
TableReadKind::Format(read) => read.to_arrow(data_splits),
}
}
@@ -373,8 +425,10 @@ impl<'a> TableRead<'a> {
data_splits: &[DataSplit],
) -> crate::Result<ArrowRecordBatchStream> {
// Decided from the splits by the `to_arrow` each branch ends in.
- match &self.0 {
- TableReadKind::Paimon(read) =>
read.to_arrow_with_row_kind(data_splits),
+ match &self.kind {
+ TableReadKind::Paimon(read) => {
+ read.execute_splits(data_splits, self.parallelism,
self.ordered, true)
+ }
TableReadKind::Format(read) => {
let schema = audit_schema_for_read_type(read.read_type(),
false)?;
prepend_insert_row_kind_stream(read.to_arrow(data_splits)?,
schema)
@@ -392,7 +446,7 @@ impl<'a> TableRead<'a> {
) -> crate::Result<ArrowRecordBatchStream> {
self.ensure_query_auth_allowed(plan)?;
plan.validate()?;
- match &self.0 {
+ match &self.kind {
TableReadKind::Paimon(read) => read.to_incremental_arrow(plan),
TableReadKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support incremental batch
read".to_string(),
@@ -412,7 +466,7 @@ impl<'a> TableRead<'a> {
) -> crate::Result<ArrowRecordBatchStream> {
self.ensure_query_auth_allowed(plan)?;
plan.validate()?;
- match &self.0 {
+ match &self.kind {
TableReadKind::Paimon(read) => read.to_audit_log_arrow(plan),
TableReadKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support audit log batch
read".to_string(),
@@ -456,6 +510,24 @@ struct PaimonTableRead<'a> {
update_read: bool,
}
+fn execution_parallelism(
+ parallelism: usize,
+ limit: Option<usize>,
+ unfiltered: bool,
+ resolves_blob: bool,
+) -> usize {
+ let parallelism = if resolves_blob {
+ parallelism.min(MAX_TOTAL_BLOB_READS)
+ } else {
+ parallelism
+ };
+ match limit {
+ Some(_) if resolves_blob => 1,
+ Some(limit) if unfiltered => parallelism.min(limit.max(1)),
+ _ => parallelism,
+ }
+}
+
impl<'a> PaimonTableRead<'a> {
/// Create a new TableRead with a specific read type (projected fields).
pub fn new(
@@ -478,6 +550,125 @@ impl<'a> PaimonTableRead<'a> {
}
}
+ fn execute_splits(
+ &self,
+ splits: &[DataSplit],
+ parallelism: usize,
+ ordered: bool,
+ include_row_kind: bool,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ let options = self.table.schema().core_options();
+ // Validate the complete grant set before splitting the work.
Per-worker
+ // validation alone could accept incompatible restricted policies.
+ let grant = self.ensure_authorized_by_splits(&options, splits)?;
+ let splits = self.matching_splits(splits)?;
+ let budget = self.parquet_read_budget()?;
+ // Preserve eager schema/configuration errors (including restricted
+ // extraction validation). Constructing the streams performs no file
I/O.
+ // Reuse the existing readers' checks instead of duplicating their
rules.
+ let mut validation = self.clone();
+ validation.parquet_read_budget = Some(Arc::clone(&budget));
+ validation.resources = None;
+ if include_row_kind {
+ drop(validation.to_arrow_with_row_kind(&splits)?);
+ } else {
+ drop(validation.to_arrow(&splits)?);
+ }
+ let mut blob_fields = options.blob_fields();
+ blob_fields.extend(
+ self.table
+ .schema()
+ .fields()
+ .iter()
+ .filter(|field| {
+
super::managed_blob_writer::managed_blob_kind(field.data_type()).is_some()
+ })
+ .map(|field| field.name().to_string()),
+ );
+ let views = options.blob_view_fields();
+ blob_fields.retain(|name| !views.contains(name));
+ let operands = super::query_auth::leaf_names(&self.data_predicates);
+ let uses = |fields: &std::collections::HashSet<String>| {
+ (grant.is_some() && !fields.is_empty())
+ || self
+ .read_type
+ .iter()
+ .any(|field| fields.contains(field.name()))
+ || !fields.is_disjoint(&operands)
+ };
+ // Captured masks and row policies may introduce hidden BLOB operands.
+ // View descriptors still require REST resolution in descriptor mode.
+ let resolves_blob = (!options.blob_as_descriptor() &&
uses(&blob_fields))
+ || (self.table.rest_env().is_some()
+ && options.blob_view_resolve_enabled()
+ && uses(&views));
+ let parallelism = execution_parallelism(
+ parallelism,
+ self.limit,
+ self.data_predicates.is_empty() &&
self.partition_predicate.is_none(),
+ resolves_blob,
+ );
+ let workers = parallelism.min(splits.len()).max(1);
+ // A later ordered group may retain its ready batch while the current
+ // group is still reading. Background prefetch must not keep a shared
+ // scheduling permit in that retained decoder and block the current
+ // group forever. Foreground reads retain shared memory admission.
+ let budget = if ordered && workers > 1 {
+ Arc::new(budget.without_prefetch())
+ } else {
+ budget
+ };
+ let blob_parallelism = self
+ .blob_parallelism
+ .min((MAX_TOTAL_BLOB_READS / workers).max(1));
+ let factory = self.execution_factory(include_row_kind, budget,
blob_parallelism);
+ Ok(super::read_execution::execute(
+ splits.into_owned(),
+ parallelism,
+ ordered,
+ self.limit,
+ factory,
+ ))
+ }
+
+ fn execution_factory(
+ &self,
+ include_row_kind: bool,
+ budget: Arc<ReadBudget>,
+ blob_parallelism: usize,
+ ) -> super::read_execution::SplitReader {
+ // The returned streams own their table/configuration; neither this
+ // TableRead nor its caller needs to outlive asynchronous execution.
+ let table = self.table.clone();
+ let read_type = self.read_type.clone();
+ let data_predicates = self.data_predicates.clone();
+ let partition_predicate = self.partition_predicate.clone();
+ let row_filter_factory = self.row_filter_factory.clone();
+ let data_file_read_timing = self.data_file_read_timing.clone();
+ let update_read = self.update_read;
+ Arc::new(move |splits, limit| {
+ let read = PaimonTableRead {
+ table: &table,
+ read_type: read_type.clone(),
+ data_predicates: data_predicates.clone(),
+ partition_predicate: partition_predicate.clone(),
+ row_filter_factory: row_filter_factory.clone(),
+ parquet_read_budget: Some(Arc::clone(&budget)),
+ // The shared budget already carries this read's resources.
+ resources: None,
+ data_file_read_timing: data_file_read_timing.clone(),
+ blob_parallelism,
+ limit,
+ update_read,
+ };
+ if include_row_kind {
+ read.to_arrow_with_row_kind(splits)
+ } else {
+ read.to_arrow(splits)
+ }
+ })
+ }
+
/// Schema (fields) that this read will produce.
pub fn read_type(&self) -> &[DataField] {
&self.read_type
@@ -2762,6 +2953,21 @@ fn pk_split_needs_merge(split: &DataSplit, dv_enabled:
bool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
+
+ #[test]
+ fn split_execution_worker_policy_limits_speculation() {
+ assert_eq!(execution_parallelism(8, None, true, false), 8);
+ assert_eq!(execution_parallelism(8, Some(0), true, false), 1);
+ assert_eq!(execution_parallelism(8, Some(2), true, false), 2);
+ assert_eq!(execution_parallelism(8, Some(2), false, false), 8);
+ assert_eq!(execution_parallelism(8, Some(100), false, true), 1);
+ assert_eq!(execution_parallelism(8, None, false, true), 8);
+ assert_eq!(execution_parallelism(64, None, false, true), 64);
+ assert_eq!(execution_parallelism(128, None, false, true), 64);
+ assert_eq!(execution_parallelism(128, None, false, false), 128);
+ assert_eq!(execution_parallelism(128, Some(100), false, true), 1);
+ }
+
use crate::catalog::Identifier;
use crate::common::Options;
use crate::file_index::file_indexer_factory::{FileIndexerFactory,
BITMAP_INDEX};
diff --git a/crates/paimon/tests/parallel_table_read_test.rs
b/crates/paimon/tests/parallel_table_read_test.rs
new file mode 100644
index 00000000..ef2d7890
--- /dev/null
+++ b/crates/paimon/tests/parallel_table_read_test.rs
@@ -0,0 +1,589 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, LargeBinaryArray, RecordBatch,
StringArray};
+use bytes::Bytes;
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::arrow::ReadBudget;
+use paimon::io::FileBlockCache;
+use paimon::spec::{
+ BinaryRow, BlobType, DataType, Datum, IntType, PredicateBuilder, Schema,
TableSchema,
+};
+use paimon::table::{DataSplitBuilder, Table};
+use parquet::arrow::ArrowWriter;
+use parquet::basic::Compression;
+use parquet::file::properties::WriterProperties;
+use std::ops::Range;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use tokio::sync::Notify;
+
+async fn table(mode: &str) -> Table {
+ let mut schema = Schema::builder()
+ .column("p", DataType::Int(IntType::new()))
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .partition_keys(["p"])
+ .option("read.batch-size", "1");
+ if mode == "pk" {
+ schema = schema.primary_key(["p", "id"]).option("bucket", "1");
+ } else if mode == "de" {
+ schema = schema
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ let (io, table) = memory_table(
+ &format!("memory:/parallel_table_read_{mode}"),
+ TableSchema::new(0, &schema.build().unwrap()),
+ );
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ for generation in 0..if mode == "pk" { 2 } else { 1 } {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ for partition in 0..4 {
+ let batch = RecordBatch::try_from_iter([
+ (
+ "p",
+ Arc::new(Int32Array::from(vec![partition; 3])) as ArrayRef,
+ ),
+ ("id", Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![generation * 100 +
partition; 3])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ }
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ }
+ table
+}
+
+fn rows(batches: &[RecordBatch]) -> Vec<(i32, i32, i32)> {
+ batches
+ .iter()
+ .flat_map(|batch| {
+ let values = ["p", "id", "value"].map(|name| {
+ batch
+ .column_by_name(name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ });
+ (0..batch.num_rows()).map(move |row| {
+ (
+ values[0].value(row),
+ values[1].value(row),
+ values[2].value(row),
+ )
+ })
+ })
+ .collect()
+}
+
+#[tokio::test]
+async fn
parallel_reads_preserve_requested_order_and_enforce_one_global_limit() {
+ for mode in ["append", "de", "pk"] {
+ let table = table(mode).await;
+ let mut splits = table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .to_vec();
+ assert_eq!(splits.len(), 4, "{mode}");
+ splits.reverse();
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .greater_than("id", Datum::Int(1))
+ .unwrap();
+ let mut builder = table.new_read_builder();
+ builder.with_filter(predicate);
+ let mut expected = Vec::new();
+ for split in &splits {
+ let batches = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(std::slice::from_ref(split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ expected.extend(rows(&batches));
+ }
+ assert_eq!(expected.len(), 8);
+ if mode == "pk" {
+ assert!(expected.iter().all(|row| row.2 >= 100));
+ }
+ for parallelism in [1, 2, 8] {
+ for ordered in [false, true] {
+ for limit in [None, Some(0), Some(3), Some(100)] {
+ let mut limited = builder.clone();
+ if let Some(limit) = limit {
+ limited.with_limit(limit);
+ }
+ let read = limited
+ .new_read()
+ .unwrap()
+ .with_parallelism(parallelism)
+ .unwrap()
+ .with_ordered(ordered)
+ // One shared slot must not deadlock while workers
wait.
+ .with_parquet_read_budget(Arc::new(
+ ReadBudget::new(1, 1024 * 1024).unwrap(),
+ ));
+ let batches = tokio::time::timeout(
+ std::time::Duration::from_secs(15),
+
read.to_arrow(&splits).unwrap().try_collect::<Vec<_>>(),
+ )
+ .await
+ .expect("parallel read stalled on the shared budget")
+ .unwrap();
+ let result = rows(&batches);
+ assert_eq!(
+ result.len(),
+ limit.unwrap_or(8).min(8),
+ "{mode}, p={parallelism}, limit={limit:?}"
+ );
+ if ordered || parallelism == 1 {
+ assert_eq!(result, expected[..result.len()]);
+ } else {
+ assert!(result.iter().all(|row|
expected.contains(row)));
+ let mut unique = result.clone();
+ unique.sort_unstable();
+ unique.dedup();
+ assert_eq!(unique.len(), result.len());
+ }
+ }
+ }
+ }
+ let batches = builder
+ .new_read()
+ .unwrap()
+ .with_parallelism(3)
+ .unwrap()
+ .with_ordered(true)
+ .to_arrow_with_row_kind(&splits)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(rows(&batches), expected);
+ assert!(batches.iter().all(|batch| batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .iter()
+ .all(|kind| kind == Some("+I"))));
+ let mut zero = builder.clone();
+ zero.with_projection(&[]).unwrap().with_limit(5);
+ let batches = zero
+ .new_read()
+ .unwrap()
+ .with_parallelism(3)
+ .unwrap()
+ .to_arrow(&splits)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(),
5);
+ assert!(batches.iter().all(|batch| batch.num_columns() == 0));
+ }
+}
+
+#[tokio::test]
+async fn data_evolution_streaming_splits_preserve_order_and_limit() {
+ let table = table("de").await;
+ let mut planned = table
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .to_vec();
+ assert_eq!(planned.len(), 4);
+ planned.reverse();
+
+ for mixed in [false, true] {
+ let splits = planned
+ .iter()
+ .enumerate()
+ .map(|(index, split)| {
+ let mut builder = DataSplitBuilder::new()
+ .with_snapshot(split.snapshot_id())
+ .with_partition(split.partition().clone())
+ .with_bucket(split.bucket())
+ .with_bucket_path(split.bucket_path().to_string())
+ .with_total_buckets(split.total_buckets())
+ .with_data_files(split.data_files().to_vec())
+ .with_raw_convertible(split.raw_convertible())
+ // Alternating flags keep any group of two splits mixed.
+ .with_streaming(!mixed || index % 2 == 0);
+ if let Some(files) = split.data_deletion_files() {
+ builder = builder.with_data_deletion_files(files.to_vec());
+ }
+ if let Some(ranges) = split.row_ranges() {
+ builder = builder.with_row_ranges(ranges.to_vec());
+ }
+ builder.build().unwrap()
+ })
+ .collect::<Vec<_>>();
+ let mut expected = Vec::new();
+ for split in &splits {
+ let batches = table
+ .new_read_builder()
+ .new_read()
+ .unwrap()
+ .to_arrow(std::slice::from_ref(split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ expected.extend(rows(&batches));
+ }
+ assert_eq!(expected.len(), 12);
+
+ for include_row_kind in [false, true] {
+ for parallelism in [1, 2] {
+ for ordered in [false, true] {
+ for limit in [None, Some(0), Some(1), Some(4), Some(7)] {
+ let label = format!(
+ "mixed={mixed}, row_kind={include_row_kind},
p={parallelism}, ordered={ordered}, limit={limit:?}"
+ );
+ let mut builder = table.new_read_builder();
+ if let Some(limit) = limit {
+ builder.with_limit(limit);
+ }
+ let read = builder
+ .new_read()
+ .unwrap()
+ .with_parallelism(parallelism)
+ .unwrap()
+ .with_ordered(ordered);
+ let stream = if include_row_kind {
+ read.to_arrow_with_row_kind(&splits)
+ } else {
+ read.to_arrow(&splits)
+ }
+ .unwrap();
+ let batches = tokio::time::timeout(
+ std::time::Duration::from_secs(15),
+ stream.try_collect::<Vec<_>>(),
+ )
+ .await
+ .expect("data-evolution split read stalled")
+ .unwrap();
+ let result = rows(&batches);
+ assert_eq!(result.len(), limit.unwrap_or(12).min(12),
"{label}");
+ if ordered || parallelism == 1 {
+ assert_eq!(result, expected[..result.len()],
"{label}");
+ } else {
+ assert!(result.iter().all(|row|
expected.contains(row)), "{label}");
+ let mut unique = result.clone();
+ unique.sort_unstable();
+ unique.dedup();
+ assert_eq!(unique.len(), result.len(), "{label}");
+ }
+ if include_row_kind {
+ assert!(
+ batches.iter().all(|batch| batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .iter()
+ .all(|kind| kind == Some("+I"))),
+ "{label}"
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn zero_parallelism_fails_before_any_splits_are_read() {
+ let table = table("append").await;
+ assert!(table
+ .new_read_builder()
+ .new_read()
+ .unwrap()
+ .with_parallelism(0)
+ .unwrap_err()
+ .to_string()
+ .contains("parallelism"));
+}
+
+#[tokio::test]
+async fn finite_blob_reads_do_not_open_payloads_in_later_splits() {
+ for primary_key in [false, true] {
+ let mut schema = Schema::builder()
+ .column("p", DataType::Int(IntType::new()))
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .partition_keys(["p"])
+ .option("read.batch-size", "1");
+ if primary_key {
+ schema = schema.primary_key(["p", "id"]).option("bucket", "1");
+ } else {
+ schema = schema
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ let (io, table) = memory_table(
+ &format!("memory:/parallel_blob_limit_{primary_key}"),
+ TableSchema::new(0, &schema.build().unwrap()),
+ );
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter([
+ ("p", Arc::new(Int32Array::from(vec![0, 1, 2])) as ArrayRef),
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![10, 20, 30])) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from(vec![b"a".as_slice(), b"b",
b"c"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+ let splits = plan.splits();
+ assert_eq!(splits.len(), 3);
+ let mut deleted = 0;
+ for split in &splits[1..] {
+ for status in
io.list_status_recursive(split.bucket_path()).await.unwrap() {
+ if status.path.ends_with(".blob") {
+ io.delete_file(&status.path).await.unwrap();
+ deleted += 1;
+ }
+ }
+ }
+ assert!(deleted > 0);
+ let mut limited = table.new_read_builder();
+ limited.with_limit(1).with_filter(
+ PredicateBuilder::new(table.schema().fields())
+ .greater_than("id", Datum::Int(0))
+ .unwrap(),
+ );
+ for ordered in [false, true] {
+ let batches = limited
+ .new_read()
+ .unwrap()
+ .with_parallelism(8)
+ .unwrap()
+ .with_ordered(ordered)
+ .to_arrow(splits)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ assert!(batches.iter().all(|batch| batch
+ .column_by_name("payload")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap()
+ .value(0)
+ .len()
+ == 1));
+ }
+ assert!(
+ table
+ .new_read_builder()
+ .new_read()
+ .unwrap()
+ .with_parallelism(8)
+ .unwrap()
+ .to_arrow(splits)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .is_err(),
+ "the later payloads really are missing"
+ );
+ }
+}
+
+/// Delay the first file's footer until a later file starts reading column
data.
+/// A cache miss hook supplies this ordering without changing the storage
backend.
+#[derive(Debug, Default)]
+struct DelayedFirstFile {
+ first_waited: AtomicBool,
+ later_data_started: AtomicBool,
+ release_first: Notify,
+}
+
+#[async_trait::async_trait]
+impl FileBlockCache for DelayedFirstFile {
+ async fn get(&self, path: &str, range: Range<u64>) -> Option<Bytes> {
+ if path.ends_with("data-later.parquet") && range.start == 0 {
+ self.later_data_started.store(true, Ordering::SeqCst);
+ self.release_first.notify_waiters();
+ }
+ if path.ends_with("data-first.parquet") {
+ self.first_waited.store(true, Ordering::SeqCst);
+ let released = self.release_first.notified();
+ if !self.later_data_started.load(Ordering::SeqCst) {
+ released.await;
+ }
+ }
+ None
+ }
+
+ async fn put(&self, _path: &str, _offset: u64, _data: Bytes) {}
+ async fn invalidate_path(&self, _path: &str) {}
+ async fn invalidate_prefix(&self, _prefix: &str) {}
+}
+
+#[tokio::test]
+async fn ordered_split_reads_do_not_wait_on_a_later_row_groups_budget_permit()
{
+ const GROUP_ROWS: usize = 150_000;
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .option("read.batch-size", "1")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table(
+ "memory:/ordered_shared_row_group_budget",
+ TableSchema::new(0, &schema),
+ );
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let batch = RecordBatch::try_from_iter([(
+ "id",
+ Arc::new(Int32Array::from_iter_values(0..(2 * GROUP_ROWS) as i32)) as
ArrayRef,
+ )])
+ .unwrap();
+ let props = WriterProperties::builder()
+ .set_max_row_group_row_count(Some(GROUP_ROWS))
+ .set_compression(Compression::UNCOMPRESSED)
+ .set_dictionary_enabled(false)
+ .build();
+ let mut parquet = Vec::new();
+ let mut writer = ArrowWriter::try_new(&mut parquet, batch.schema(),
Some(props)).unwrap();
+ writer.write(&batch).unwrap();
+ let metadata = writer.close().unwrap();
+ assert_eq!(metadata.num_row_groups(), 2);
+ // Parquet prefetches the final 512 KiB for metadata. Keeping the first
+ // data block outside that suffix makes the hook identify column I/O.
+ assert!(parquet.len() > 1024 * 1024);
+
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch.slice(0, 1)).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let template = &messages[0].new_files[0];
+ let bucket_path = format!("{}/bucket-0", table.location());
+ io.mkdirs(&bucket_path).await.unwrap();
+ let mut splits = Vec::new();
+ for name in ["data-first.parquet", "data-later.parquet"] {
+ io.new_output(&format!("{bucket_path}/{name}"))
+ .unwrap()
+ .write(Bytes::copy_from_slice(&parquet))
+ .await
+ .unwrap();
+ let mut file = template.clone();
+ file.file_name = name.to_string();
+ file.file_size = parquet.len() as i64;
+ file.row_count = (2 * GROUP_ROWS) as i64;
+ file.external_path = None;
+ splits.push(
+ DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(bucket_path.clone())
+ .with_total_buckets(1)
+ .with_data_files(vec![file])
+ .build()
+ .unwrap(),
+ );
+ }
+ let order = Arc::new(DelayedFirstFile::default());
+ let io = io
+ .with_file_block_cache(order.clone(), 64 * 1024, "data")
+ .unwrap();
+ let table = Table::new(
+ io,
+ table.identifier().clone(),
+ table.location().into(),
+ table.schema().clone(),
+ None,
+ );
+ let mut builder = table.new_read_builder();
+ builder.with_limit(3);
+ let output = builder
+ .new_read()
+ .unwrap()
+ .with_parallelism(2)
+ .unwrap()
+ .with_ordered(true)
+ // Two row-group workers enable Parquet prefetch; the 1 MiB byte
+ // window admits only one projected 600 KiB row group at a time.
+ .with_parquet_read_budget(Arc::new(ReadBudget::new(2, 1024 *
1024).unwrap()))
+ .to_arrow(&splits)
+ .unwrap();
+ let batches = tokio::time::timeout(
+ std::time::Duration::from_secs(10),
+ output.try_collect::<Vec<_>>(),
+ )
+ .await
+ .expect("ordered splits deadlocked behind a later row-group permit")
+ .unwrap();
+ assert!(order.first_waited.load(Ordering::SeqCst));
+ assert!(order.later_data_started.load(Ordering::SeqCst));
+ let ids = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(ids, [0, 1, 2]);
+}
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index cc4d3f2e..164f76b2 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -3808,6 +3808,109 @@ async fn
test_query_auth_blob_view_predicate_limit_groups_lookups() {
}
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_query_auth_blob_view_limit_skips_rejected_split() {
+ let payload = b"admitted-payload".to_vec();
+ let source = written(
+ "rejected_split_source",
+ blob_schema,
+ vec![blob_batch(vec![1], vec!["Source"], vec![payload.clone()])],
+ )
+ .await;
+ let picture_id = source.table.schema().fields()[2].id();
+ let reference = |row_id| {
+ BlobViewStruct::new(source.identifier.clone(), picture_id, row_id)
+ .serialize()
+ .unwrap()
+ };
+ let target = written(
+ "rejected_split_target",
+ |options| {
+ blob_schema(
+ &[
+ options,
+ &[
+ ("blob-view-field", "picture"),
+ ("source.split.target-size", "1b"),
+ ],
+ ]
+ .concat(),
+ )
+ },
+ vec![
+ blob_batch(vec![0], vec!["Rejected"], vec![reference(99)]),
+ blob_batch(vec![1], vec!["Admitted"], vec![reference(0)]),
+ ],
+ )
+ .await;
+ let server = &target.ctx.server;
+ server.add_table_with_schema(
+ "default",
+ "rejected_split_source",
+ blob_schema(&[]),
+ source.table.location(),
+ );
+ target.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 0)], &[]);
+ let mut builder = target.table.new_read_builder();
+ builder.with_limit(1);
+ let plan = builder.new_scan().plan().await.unwrap();
+ assert_eq!(plan.splits().len(), 2);
+
+ // Probe only ids so locating the rejected split cannot resolve its bad
view.
+ let mut projected = target.table.new_read_builder();
+ projected.with_projection(&["id"]).unwrap();
+ let probe = projected.new_read().unwrap();
+ let mut rejected = None;
+ let mut admitted = None;
+ for split in plan.splits() {
+ let batches = probe
+ .to_arrow(std::slice::from_ref(split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let returned = ids(&batches);
+ if returned.is_empty() {
+ rejected = Some(split.clone());
+ } else {
+ assert_eq!(returned, vec![1]);
+ admitted = Some(split.clone());
+ }
+ }
+ let splits = vec![rejected.unwrap(), admitted.unwrap()];
+
+ for (include_row_kind, ordered) in [(false, false), (true, true)] {
+ let label = format!("row_kind={include_row_kind}, ordered={ordered}");
+ let read = builder.new_read().unwrap().with_ordered(ordered);
+ let stream = if include_row_kind {
+ read.to_arrow_with_row_kind(&splits)
+ } else {
+ read.to_arrow(&splits)
+ }
+ .unwrap();
+ let batches = stream.try_collect::<Vec<_>>().await.unwrap();
+ let payload_batches: Vec<_> = batches
+ .iter()
+ .map(|batch| {
+ if include_row_kind {
+ let kinds = column::<StringArray>(batch, "rowkind");
+ assert_eq!(kinds.null_count(), 0, "{label}");
+ assert!(kinds.iter().all(|kind| kind == Some("+I")),
"{label}");
+ batch.project(&[1, 2, 3]).unwrap()
+ } else {
+ batch.clone()
+ }
+ })
+ .collect();
+ assert_eq!(
+ collect_blob_rows(&payload_batches),
+ vec![(1, "Admitted".to_string(), Some(payload.clone()))],
+ "{label}"
+ );
+ }
+}
+
fn schema_of(columns: &[&str], options: &[(&str, &str)]) -> Schema {
let mut builder = Schema::builder();
for name in columns {