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 4a73e4a fix: stabilize Vortex runtime for async IO (#387)
4a73e4a is described below
commit 4a73e4a083e632fb4431a71edc92afdc4014c06d
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jun 16 11:57:41 2026 +0800
fix: stabilize Vortex runtime for async IO (#387)
---
crates/paimon/Cargo.toml | 5 +-
crates/paimon/src/arrow/format/vortex.rs | 1845 ++++++++++++++++++------------
crates/paimon/src/table/table_write.rs | 42 +
3 files changed, 1133 insertions(+), 759 deletions(-)
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 801ac27..0741a50 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -42,7 +42,7 @@ storage-all = [
"storage-hdfs",
]
fulltext = ["tantivy", "tempfile"]
-vortex = ["dep:vortex", "dep:kanal"]
+vortex = ["dep:vortex"]
storage-memory = ["opendal/services-memory"]
storage-fs = ["opendal/services-fs"]
@@ -102,11 +102,10 @@ urlencoding = "2.1"
tantivy = { version = "0.22", optional = true }
tempfile = { version = "3", optional = true }
vortex = { version = "0.68", features = ["tokio"], optional = true }
-kanal = { version = "0.1.1", optional = true }
libloading = "0.9"
# Keep CI on the dependency set that passed before unicode-segmentation 1.13.3.
# The 1.13.3 resolver update correlates with Linux Vortex tests hanging.
-unicode-segmentation = "=1.13.3"
+unicode-segmentation = "=1.13.2"
[dev-dependencies]
axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
diff --git a/crates/paimon/src/arrow/format/vortex.rs
b/crates/paimon/src/arrow/format/vortex.rs
index 6dfce9c..2a77e13 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -17,131 +17,47 @@
use super::{FilePredicates, FormatFileReader, FormatFileWriter};
use crate::io::{FileRead, OutputFile};
-use crate::spec::{DataField, Datum, Predicate, PredicateOperator};
+use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
-use arrow_array::RecordBatch;
-use arrow_schema::{DataType as ArrowDataType, SchemaRef};
+use arrow_array::{
+ Array, ArrayRef as ArrowArrayRef, BinaryArray, BooleanArray, Date32Array,
Decimal128Array,
+ Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
RecordBatch, Scalar,
+ StringArray, Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
+ TimestampNanosecondArray,
+};
+use arrow_ord::cmp::{
+ eq as arrow_eq, gt as arrow_gt, gt_eq as arrow_gt_eq, lt as arrow_lt,
lt_eq as arrow_lt_eq,
+ neq as arrow_neq,
+};
+use arrow_schema::{ArrowError, DataType as ArrowDataType, SchemaRef};
use async_trait::async_trait;
-use futures::future::BoxFuture;
-use futures::Stream;
-use futures::StreamExt;
-use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
-use std::sync::Arc;
-use std::task::{Context, Poll};
+use std::sync::{Arc, OnceLock};
use vortex::array::arrow::{FromArrowArray, IntoArrowArray};
use vortex::array::dtype::arrow::FromArrowType;
use vortex::array::dtype::DType;
-use vortex::array::expr::{
- and_collect, col, eq, gt, gt_eq, is_null, lit, lt, lt_eq, not, not_eq,
or_collect, Expression,
-};
-use vortex::array::stream::{ArrayStreamAdapter, ArrayStreamExt};
use vortex::array::ArrayRef;
-use vortex::buffer::{Alignment, ByteBuffer};
-use vortex::error::VortexResult;
+use vortex::buffer::ByteBuffer;
use vortex::file::{OpenOptionsSessionExt, WriteOptionsSessionExt};
-use vortex::io::runtime::tokio::TokioRuntime;
+use vortex::io::runtime::current::CurrentThreadRuntime;
use vortex::io::runtime::BlockingRuntime;
use vortex::io::session::RuntimeSessionExt;
-use vortex::io::{IoBuf, VortexReadAt, VortexWrite};
+use vortex::layout::scan::split_by::SplitBy;
use vortex::scan::selection::Selection;
use vortex::session::VortexSession;
use vortex::VortexSessionDefault;
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-/// Maximum number of concurrent read requests for Vortex file IO.
-const DEFAULT_READ_CONCURRENCY: usize = 10;
-
-// ---------------------------------------------------------------------------
-// Vortex Runtime
-// ---------------------------------------------------------------------------
-
-struct PaimonVortexRuntime {
- runtime: TokioRuntime,
-}
-
-impl PaimonVortexRuntime {
- fn new() -> crate::Result<Arc<Self>> {
- let handle = tokio::runtime::Handle::try_current().map_err(|e|
Error::DataInvalid {
- message: format!("Vortex requires an active Tokio runtime: {e}"),
+async fn acquire_vortex_io_permit() ->
crate::Result<tokio::sync::SemaphorePermit<'static>> {
+ static SEMAPHORE: OnceLock<tokio::sync::Semaphore> = OnceLock::new();
+ SEMAPHORE
+ .get_or_init(|| tokio::sync::Semaphore::new(1))
+ .acquire()
+ .await
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to acquire Vortex I/O permit: {e}"),
source: None,
- })?;
- Ok(Arc::new(Self {
- runtime: TokioRuntime::new(handle),
- }))
- }
-
- fn session(&self) -> VortexSession {
- VortexSession::default().with_handle(self.runtime.handle())
- }
-}
-
-fn new_vortex_session() -> crate::Result<(VortexSession,
Arc<PaimonVortexRuntime>)> {
- let runtime = PaimonVortexRuntime::new()?;
- let session = runtime.session();
- Ok((session, runtime))
-}
-
-struct VortexRecordBatchStream {
- inner: ArrowRecordBatchStream,
- _runtime: Arc<PaimonVortexRuntime>,
-}
-
-impl Unpin for VortexRecordBatchStream {}
-
-impl Stream for VortexRecordBatchStream {
- type Item = crate::Result<RecordBatch>;
-
- fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) ->
Poll<Option<Self::Item>> {
- self.get_mut().inner.as_mut().poll_next(cx)
- }
-}
-
-// ---------------------------------------------------------------------------
-// IO Adapters
-// ---------------------------------------------------------------------------
-
-/// Adapts paimon's `FileRead` to Vortex's `VortexReadAt`.
-struct PaimonVortexReadAt {
- file_size: u64,
- reader: Arc<dyn FileRead>,
-}
-
-impl VortexReadAt for PaimonVortexReadAt {
- fn uri(&self) -> Option<&Arc<str>> {
- None
- }
-
- fn concurrency(&self) -> usize {
- DEFAULT_READ_CONCURRENCY
- }
-
- fn size(&self) -> futures::future::BoxFuture<'static, VortexResult<u64>> {
- let size = self.file_size;
- Box::pin(async move { Ok(size) })
- }
-
- fn read_at(
- &self,
- offset: u64,
- length: usize,
- alignment: Alignment,
- ) -> BoxFuture<'static, VortexResult<vortex::array::buffer::BufferHandle>>
{
- let reader = Arc::clone(&self.reader);
- Box::pin(async move {
- let bytes = reader
- .read(offset..offset + length as u64)
- .await
- .map_err(|e| vortex::error::vortex_err!("paimon read error:
{e}"))?;
- // Zero-copy when the Bytes pointer is already aligned; falls back
to copy otherwise.
- let buffer = ByteBuffer::from(bytes).aligned(alignment);
- Ok(vortex::array::buffer::BufferHandle::new_host(buffer))
})
- }
}
// ---------------------------------------------------------------------------
@@ -161,101 +77,203 @@ impl FormatFileReader for VortexFormatReader {
_batch_size: Option<usize>,
row_selection: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
- let (session, runtime) = new_vortex_session()?;
-
- let source = Arc::new(PaimonVortexReadAt {
- file_size,
- reader: Arc::from(reader),
+ let bytes = reader.read(0..file_size).await?;
+ let target_schema =
crate::arrow::build_target_arrow_schema(read_fields)?;
+ let read_fields = read_fields.to_vec();
+ let predicates = predicates.map(|fp| FilePredicates {
+ predicates: fp.predicates.clone(),
+ file_fields: fp.file_fields.clone(),
});
+ let scan_fields = build_vortex_scan_fields(&read_fields,
predicates.as_ref());
+ let scan_schema =
crate::arrow::build_target_arrow_schema(&scan_fields)?;
+ let _permit = acquire_vortex_io_permit().await?;
+
+ let target_schema_for_scan = target_schema.clone();
+ let plan = VortexReadPlan {
+ target_schema: target_schema_for_scan,
+ read_fields,
+ scan_schema,
+ scan_fields,
+ predicates,
+ row_selection,
+ };
+ let batches =
+ tokio::task::spawn_blocking(move ||
read_vortex_batches_blocking(bytes, plan))
+ .await
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Vortex read task failed: {e}"),
+ source: None,
+ })??;
+
+ Ok(Box::pin(futures::stream::iter(batches.into_iter().map(Ok))))
+ }
+}
+
+struct VortexReadPlan {
+ target_schema: SchemaRef,
+ read_fields: Vec<DataField>,
+ scan_schema: SchemaRef,
+ scan_fields: Vec<DataField>,
+ predicates: Option<FilePredicates>,
+ row_selection: Option<Vec<RowRange>>,
+}
- let vortex_file = session
+fn read_vortex_batches_blocking(
+ bytes: bytes::Bytes,
+ plan: VortexReadPlan,
+) -> crate::Result<Vec<RecordBatch>> {
+ run_vortex_on_thread("paimon-vortex-read", move || {
+ let runtime = CurrentThreadRuntime::new();
+ let session = VortexSession::default().with_handle(runtime.handle());
+ read_vortex_batches(&runtime, session, ByteBuffer::from(bytes), plan)
+ })
+}
+
+fn read_vortex_batches(
+ runtime: &CurrentThreadRuntime,
+ session: VortexSession,
+ bytes: ByteBuffer,
+ plan: VortexReadPlan,
+) -> crate::Result<Vec<RecordBatch>> {
+ let VortexReadPlan {
+ target_schema,
+ read_fields,
+ scan_schema,
+ scan_fields,
+ predicates,
+ row_selection,
+ } = plan;
+
+ let vortex_file =
+ session
.open_options()
- .with_file_size(file_size)
- .open(source)
- .await
+ .open_buffer(bytes)
.map_err(|e| Error::DataInvalid {
message: format!("Failed to open Vortex file: {e}"),
source: None,
})?;
- // Build the target Arrow schema for the projected fields.
- let target_schema =
crate::arrow::build_target_arrow_schema(read_fields)?;
-
- if read_fields.is_empty() {
- let row_count = match &row_selection {
+ if scan_fields.is_empty() {
+ let row_count = if constant_predicates_match(predicates.as_ref()) {
+ match &row_selection {
Some(ranges) => ranges.iter().map(|r| r.count() as
usize).sum(),
None => vortex_file.row_count() as usize,
- };
- let batch = RecordBatch::try_new_with_options(
- target_schema,
- vec![],
-
&arrow_array::RecordBatchOptions::new().with_row_count(Some(row_count)),
- )
- .map_err(|e| Error::DataInvalid {
- message: format!("Failed to build empty RecordBatch: {e}"),
- source: None,
- })?;
- return Ok(Box::pin(futures::stream::once(async { Ok(batch) })));
- }
+ }
+ } else {
+ 0
+ };
+ let batch = RecordBatch::try_new_with_options(
+ target_schema,
+ vec![],
+
&arrow_array::RecordBatchOptions::new().with_row_count(Some(row_count)),
+ )
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to build empty RecordBatch: {e}"),
+ source: None,
+ })?;
+ return Ok(vec![batch]);
+ }
- // Build projection expression for requested fields.
- let projected_names: Vec<&str> = read_fields.iter().map(|f|
f.name()).collect();
+ // Build projection expression for requested fields.
+ let projected_names: Vec<&str> = scan_fields.iter().map(|f|
f.name()).collect();
- let mut scan_builder = vortex_file.scan().map_err(|e|
Error::DataInvalid {
- message: format!("Failed to create Vortex scan: {e}"),
+ let mut scan_builder = vortex_file.scan().map_err(|e| Error::DataInvalid {
+ message: format!("Failed to create Vortex scan: {e}"),
+ source: None,
+ })?;
+
+ // Apply column projection.
+ {
+ use vortex::array::expr::{root, select};
+ scan_builder = scan_builder.with_projection(select(projected_names,
root()));
+ }
+
+ // Vortex 0.68 filtered scans can block indefinitely on some runtimes.
+ // Decode predicate columns and apply the same filter with Arrow kernels
below.
+
+ // Push row selection down to Vortex.
+ // For a single contiguous range, use with_row_range directly (avoids
roaring bitmap overhead).
+ // For multiple ranges, build a Selection::IncludeRoaring bitmap.
+ if let Some(ref ranges) = row_selection {
+ let total_rows = vortex_file.row_count();
+ if let Some(range) = as_single_row_range(ranges, total_rows) {
+ scan_builder = scan_builder.with_row_range(range);
+ } else {
+ let selection = row_ranges_to_selection(ranges, total_rows);
+ scan_builder = scan_builder.with_selection(selection);
+ }
+ }
+
+ let rows_per_split = usize::try_from(vortex_file.row_count())
+ .unwrap_or(usize::MAX)
+ .max(1);
+ let vortex_tasks = scan_builder
+ .with_concurrency(1)
+ .with_split_by(SplitBy::RowCount(rows_per_split))
+ .build()
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to build Vortex scan tasks: {e}"),
source: None,
})?;
- // Apply column projection.
- {
- use vortex::array::expr::{root, select};
- scan_builder =
scan_builder.with_projection(select(projected_names, root()));
- }
+ let mut batches = Vec::with_capacity(vortex_tasks.len());
+ for task in vortex_tasks {
+ let Some(vortex_array) = runtime.block_on(task).map_err(|e|
Error::DataInvalid {
+ message: format!("Vortex read error: {e}"),
+ source: None,
+ })?
+ else {
+ continue;
+ };
+ let batch = vortex_array_to_record_batch(vortex_array, &scan_schema)?;
+ batches.push(filter_and_project_batch(
+ batch,
+ &target_schema,
+ &read_fields,
+ &scan_fields,
+ predicates.as_ref(),
+ )?);
+ }
+
+ Ok(batches)
+}
+
+fn constant_predicates_match(predicates: Option<&FilePredicates>) -> bool {
+ predicates.is_none_or(|fp| {
+ fp.predicates
+ .iter()
+ .all(|predicate|
constant_predicate_value(predicate).unwrap_or(true))
+ })
+}
- // Push predicate filter down to Vortex.
- if let Some(fp) = predicates {
- if let Some(filter_expr) =
predicates_to_vortex_expr(&fp.predicates, &fp.file_fields) {
- scan_builder = scan_builder.with_filter(filter_expr);
+fn constant_predicate_value(predicate: &Predicate) -> Option<bool> {
+ match predicate {
+ Predicate::AlwaysTrue => Some(true),
+ Predicate::AlwaysFalse => Some(false),
+ Predicate::And(children) => {
+ let mut saw_unknown = false;
+ for child in children {
+ match constant_predicate_value(child) {
+ Some(true) => {}
+ Some(false) => return Some(false),
+ None => saw_unknown = true,
+ }
}
+ (!saw_unknown).then_some(true)
}
-
- // Push row selection down to Vortex.
- // For a single contiguous range, use with_row_range directly (avoids
roaring bitmap overhead).
- // For multiple ranges, build a Selection::IncludeRoaring bitmap.
- if let Some(ref ranges) = row_selection {
- let total_rows = vortex_file.row_count();
- if let Some(range) = as_single_row_range(ranges, total_rows) {
- scan_builder = scan_builder.with_row_range(range);
- } else {
- let selection = row_ranges_to_selection(ranges, total_rows);
- scan_builder = scan_builder.with_selection(selection);
+ Predicate::Or(children) => {
+ let mut saw_unknown = false;
+ for child in children {
+ match constant_predicate_value(child) {
+ Some(true) => return Some(true),
+ Some(false) => {}
+ None => saw_unknown = true,
+ }
}
+ (!saw_unknown).then_some(false)
}
-
- let vortex_stream = scan_builder
- .into_array_stream()
- .map_err(|e| Error::DataInvalid {
- message: format!("Failed to build Vortex array stream: {e}"),
- source: None,
- })?;
-
- // Convert Vortex stream to Arrow RecordBatch stream.
- let stream = vortex_stream
- .map(move |result| {
- result.map_err(|e| Error::DataInvalid {
- message: format!("Vortex read error: {e}"),
- source: None,
- })
- })
- .map(move |result| {
- let schema = target_schema.clone();
- result.and_then(|vortex_array|
vortex_array_to_record_batch(vortex_array, &schema))
- });
-
- Ok(Box::pin(VortexRecordBatchStream {
- inner: Box::pin(stream),
- _runtime: runtime,
- }))
+ Predicate::Not(inner) => constant_predicate_value(inner).map(|value|
!value),
+ Predicate::Leaf { .. } => None,
}
}
@@ -278,224 +296,524 @@ fn as_single_row_range(ranges: &[RowRange], total_rows:
u64) -> Option<std::ops:
None
}
-// ---------------------------------------------------------------------------
-// Predicate → Vortex Expression conversion
-// ---------------------------------------------------------------------------
+fn build_vortex_scan_fields(
+ read_fields: &[DataField],
+ predicates: Option<&FilePredicates>,
+) -> Vec<DataField> {
+ let mut fields = read_fields.to_vec();
+
+ if let Some(fp) = predicates {
+ let mut predicate_indices = Vec::new();
+ for predicate in &fp.predicates {
+ collect_predicate_field_indices(predicate, &mut predicate_indices);
+ }
+ for index in predicate_indices {
+ if let Some(field) = fp.file_fields.get(index) {
+ push_unique_scan_field(&mut fields, field);
+ }
+ }
+ }
+
+ fields
+}
+
+fn collect_predicate_field_indices(predicate: &Predicate, indices: &mut
Vec<usize>) {
+ match predicate {
+ Predicate::Leaf { index, .. } => indices.push(*index),
+ Predicate::And(children) | Predicate::Or(children) => {
+ for child in children {
+ collect_predicate_field_indices(child, indices);
+ }
+ }
+ Predicate::Not(inner) => collect_predicate_field_indices(inner,
indices),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+ }
+}
+
+fn push_unique_scan_field(fields: &mut Vec<DataField>, field: &DataField) {
+ if !fields
+ .iter()
+ .any(|existing| same_data_field(existing, field))
+ {
+ fields.push(field.clone());
+ }
+}
-/// Convert a list of Paimon predicates (ANDed together) into a single Vortex
filter expression.
-fn predicates_to_vortex_expr(
+fn same_data_field(left: &DataField, right: &DataField) -> bool {
+ left.id() == right.id() || left.name() == right.name()
+}
+
+fn filter_and_project_batch(
+ batch: RecordBatch,
+ target_schema: &SchemaRef,
+ read_fields: &[DataField],
+ scan_fields: &[DataField],
+ predicates: Option<&FilePredicates>,
+) -> crate::Result<RecordBatch> {
+ let filtered = match predicates {
+ Some(fp) => filter_record_batch_by_predicates(batch, fp, scan_fields)?,
+ None => batch,
+ };
+
+ if read_fields.is_empty() {
+ return RecordBatch::try_new_with_options(
+ target_schema.clone(),
+ vec![],
+
&arrow_array::RecordBatchOptions::new().with_row_count(Some(filtered.num_rows())),
+ )
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to build projected empty RecordBatch:
{e}"),
+ source: None,
+ });
+ }
+
+ let columns = projection_indices(read_fields, scan_fields)?
+ .into_iter()
+ .map(|index| filtered.column(index).clone())
+ .collect::<Vec<_>>();
+
+ RecordBatch::try_new(target_schema.clone(), columns).map_err(|e|
Error::DataInvalid {
+ message: format!("Failed to project Vortex RecordBatch: {e}"),
+ source: None,
+ })
+}
+
+fn projection_indices(
+ read_fields: &[DataField],
+ scan_fields: &[DataField],
+) -> crate::Result<Vec<usize>> {
+ read_fields
+ .iter()
+ .map(|field| {
+ scan_fields
+ .iter()
+ .position(|scan_field| same_data_field(scan_field, field))
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Projected Vortex field {} was not included in the
scan",
+ field.name()
+ ),
+ source: None,
+ })
+ })
+ .collect()
+}
+
+fn filter_record_batch_by_predicates(
+ batch: RecordBatch,
+ predicates: &FilePredicates,
+ scan_fields: &[DataField],
+) -> crate::Result<RecordBatch> {
+ let Some(mask) = evaluate_predicates_mask(
+ &batch,
+ &predicates.predicates,
+ &predicates.file_fields,
+ scan_fields,
+ )?
+ else {
+ return Ok(batch);
+ };
+
+ arrow_select::filter::filter_record_batch(&batch, &mask).map_err(|e|
Error::DataInvalid {
+ message: format!("Failed to filter Vortex RecordBatch: {e}"),
+ source: Some(Box::new(e)),
+ })
+}
+
+fn evaluate_predicates_mask(
+ batch: &RecordBatch,
predicates: &[Predicate],
file_fields: &[DataField],
-) -> Option<Expression> {
- let exprs: Vec<Expression> = predicates
- .iter()
- .filter_map(|p| predicate_to_vortex_expr(p, file_fields))
- .collect();
- and_collect(exprs)
+ scan_fields: &[DataField],
+) -> crate::Result<Option<BooleanArray>> {
+ let mut combined = None;
+ for predicate in predicates {
+ let Some(mask) = evaluate_predicate_mask(batch, predicate,
file_fields, scan_fields)?
+ else {
+ continue;
+ };
+ combined = Some(match combined {
+ Some(existing) => combine_filter_masks(&existing, &mask, false),
+ None => mask,
+ });
+ }
+ Ok(combined)
}
-/// Convert a single Paimon `Predicate` tree node into a Vortex `Expression`.
-fn predicate_to_vortex_expr(
+fn evaluate_predicate_mask(
+ batch: &RecordBatch,
predicate: &Predicate,
file_fields: &[DataField],
-) -> Option<Expression> {
+ scan_fields: &[DataField],
+) -> crate::Result<Option<BooleanArray>> {
match predicate {
- Predicate::AlwaysTrue => Some(lit(true)),
- Predicate::AlwaysFalse => Some(lit(false)),
+ Predicate::AlwaysTrue => Ok(Some(BooleanArray::from(vec![true;
batch.num_rows()]))),
+ Predicate::AlwaysFalse => Ok(Some(BooleanArray::from(vec![false;
batch.num_rows()]))),
Predicate::And(children) => {
- // Dropping unconvertible children is safe for AND: it makes the
filter
- // less restrictive, so no matching rows are incorrectly excluded.
- let exprs: Vec<Expression> = children
- .iter()
- .filter_map(|c| predicate_to_vortex_expr(c, file_fields))
- .collect();
- and_collect(exprs)
+ let mut combined = None;
+ for child in children {
+ let Some(mask) = evaluate_predicate_mask(batch, child,
file_fields, scan_fields)?
+ else {
+ continue;
+ };
+ combined = Some(match combined {
+ Some(existing) => combine_filter_masks(&existing, &mask,
false),
+ None => mask,
+ });
+ }
+ Ok(combined)
}
Predicate::Or(children) => {
- // All children must be convertible; otherwise skip the entire OR
- // to avoid incorrectly filtering out rows that match unconverted
branches.
- let exprs: Vec<Expression> = children
- .iter()
- .map(|c| predicate_to_vortex_expr(c, file_fields))
- .collect::<Option<Vec<_>>>()?;
- or_collect(exprs)
+ let mut combined = BooleanArray::from(vec![false;
batch.num_rows()]);
+ for child in children {
+ let Some(mask) = evaluate_predicate_mask(batch, child,
file_fields, scan_fields)?
+ else {
+ return Ok(None);
+ };
+ combined = combine_filter_masks(&combined, &mask, true);
+ }
+ Ok(Some(combined))
+ }
+ Predicate::Not(inner) => {
+ let Some(mask) = evaluate_predicate_mask(batch, inner,
file_fields, scan_fields)?
+ else {
+ return Ok(None);
+ };
+ Ok(Some(boolean_mask_from_predicate(mask.len(), |row_index| {
+ !mask.value(row_index)
+ })))
}
- Predicate::Not(inner) => predicate_to_vortex_expr(inner,
file_fields).map(not),
Predicate::Leaf {
- column,
index,
op,
literals,
..
- } => leaf_to_vortex_expr(column, *index, *op, literals, file_fields),
+ } => {
+ let Some(file_field) = file_fields.get(*index) else {
+ return Ok(None);
+ };
+ let Some(scan_index) = scan_fields
+ .iter()
+ .position(|scan_field| same_data_field(scan_field, file_field))
+ else {
+ return Ok(None);
+ };
+ evaluate_arrow_leaf_predicate(
+ batch.column(scan_index),
+ file_field.data_type(),
+ *op,
+ literals,
+ )
+ }
}
}
-/// Convert a leaf predicate to a Vortex expression.
-fn leaf_to_vortex_expr(
- _column: &str,
- index: usize,
+fn evaluate_arrow_leaf_predicate(
+ array: &ArrowArrayRef,
+ data_type: &DataType,
op: PredicateOperator,
literals: &[Datum],
- file_fields: &[DataField],
-) -> Option<Expression> {
- let file_field = file_fields.get(index)?;
- // Use the file-level column name for the Vortex expression.
- let column_expr = col(file_field.name());
-
+) -> crate::Result<Option<BooleanArray>> {
match op {
- PredicateOperator::IsNull => Some(is_null(column_expr)),
- PredicateOperator::IsNotNull => Some(not(is_null(column_expr))),
- PredicateOperator::Eq => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(eq(column_expr, v))
- }
- PredicateOperator::NotEq => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(not_eq(column_expr, v))
- }
- PredicateOperator::Lt => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(lt(column_expr, v))
- }
- PredicateOperator::LtEq => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(lt_eq(column_expr, v))
+ PredicateOperator::IsNull => Ok(Some(boolean_mask_from_predicate(
+ array.len(),
+ |row_index| array.is_null(row_index),
+ ))),
+ PredicateOperator::IsNotNull => Ok(Some(boolean_mask_from_predicate(
+ array.len(),
+ |row_index| array.is_valid(row_index),
+ ))),
+ PredicateOperator::In | PredicateOperator::NotIn => {
+ evaluate_set_membership_predicate(array, data_type, op, literals)
}
- PredicateOperator::Gt => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(gt(column_expr, v))
- }
- PredicateOperator::GtEq => {
- let v = datum_to_vortex_lit(literals.first()?, file_field)?;
- Some(gt_eq(column_expr, v))
- }
- PredicateOperator::In => {
- // OR of eq for each literal value.
- // All literals must be convertible; otherwise skip the entire
predicate
- // to avoid incorrectly filtering out rows that match unconverted
literals.
- let exprs: Vec<Expression> = literals
- .iter()
- .map(|d| datum_to_vortex_lit(d, file_field).map(|v|
eq(col(file_field.name()), v)))
- .collect::<Option<Vec<_>>>()?;
- or_collect(exprs)
+ PredicateOperator::Eq
+ | PredicateOperator::NotEq
+ | PredicateOperator::Lt
+ | PredicateOperator::LtEq
+ | PredicateOperator::Gt
+ | PredicateOperator::GtEq => {
+ let Some(literal) = literals.first() else {
+ return Ok(None);
+ };
+ let Some(scalar) = literal_scalar_for_arrow_filter(literal,
data_type)? else {
+ return Ok(None);
+ };
+ let mask =
+ evaluate_column_predicate(array, &scalar, op).map_err(|e|
Error::DataInvalid {
+ message: format!("Failed to evaluate Vortex predicate:
{e}"),
+ source: Some(Box::new(e)),
+ })?;
+ Ok(Some(sanitize_filter_mask(mask)))
}
+ }
+}
+
+fn evaluate_set_membership_predicate(
+ array: &ArrowArrayRef,
+ data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+) -> crate::Result<Option<BooleanArray>> {
+ if literals.is_empty() {
+ return Ok(Some(match op {
+ PredicateOperator::In => BooleanArray::from(vec![false;
array.len()]),
+ PredicateOperator::NotIn => {
+ boolean_mask_from_predicate(array.len(), |row_index|
array.is_valid(row_index))
+ }
+ _ => unreachable!(),
+ }));
+ }
+
+ let mut combined = match op {
+ PredicateOperator::In => BooleanArray::from(vec![false; array.len()]),
PredicateOperator::NotIn => {
- // AND of not_eq for each literal value.
- // All literals must be convertible; otherwise skip the entire
predicate
- // to avoid incorrectly keeping rows that match unconverted
literals.
- let exprs: Vec<Expression> = literals
- .iter()
- .map(|d| {
- datum_to_vortex_lit(d, file_field).map(|v|
not_eq(col(file_field.name()), v))
- })
- .collect::<Option<Vec<_>>>()?;
- and_collect(exprs)
+ boolean_mask_from_predicate(array.len(), |row_index|
array.is_valid(row_index))
}
+ _ => unreachable!(),
+ };
+
+ for literal in literals {
+ let Some(scalar) = literal_scalar_for_arrow_filter(literal,
data_type)? else {
+ return Ok(None);
+ };
+ let comparison_op = match op {
+ PredicateOperator::In => PredicateOperator::Eq,
+ PredicateOperator::NotIn => PredicateOperator::NotEq,
+ _ => unreachable!(),
+ };
+ let mask = evaluate_column_predicate(array, &scalar,
comparison_op).map_err(|e| {
+ Error::DataInvalid {
+ message: format!("Failed to evaluate Vortex set predicate:
{e}"),
+ source: Some(Box::new(e)),
+ }
+ })?;
+ let mask = sanitize_filter_mask(mask);
+ combined = combine_filter_masks(&combined, &mask, matches!(op,
PredicateOperator::In));
+ }
+
+ Ok(Some(combined))
+}
+
+fn evaluate_column_predicate(
+ column: &ArrowArrayRef,
+ scalar: &Scalar<ArrowArrayRef>,
+ op: PredicateOperator,
+) -> Result<BooleanArray, ArrowError> {
+ match op {
+ PredicateOperator::Eq => arrow_eq(column, scalar),
+ PredicateOperator::NotEq => arrow_neq(column, scalar),
+ PredicateOperator::Lt => arrow_lt(column, scalar),
+ PredicateOperator::LtEq => arrow_lt_eq(column, scalar),
+ PredicateOperator::Gt => arrow_gt(column, scalar),
+ PredicateOperator::GtEq => arrow_gt_eq(column, scalar),
+ PredicateOperator::IsNull
+ | PredicateOperator::IsNotNull
+ | PredicateOperator::In
+ | PredicateOperator::NotIn => Ok(BooleanArray::new_null(column.len())),
+ }
+}
+
+fn sanitize_filter_mask(mask: BooleanArray) -> BooleanArray {
+ if mask.null_count() == 0 {
+ return mask;
}
+
+ boolean_mask_from_predicate(mask.len(), |row_index| {
+ mask.is_valid(row_index) && mask.value(row_index)
+ })
}
-/// Convert a Paimon `Datum` to a Vortex literal `Expression`.
-/// Returns `None` for types not yet supported by this conversion.
-fn datum_to_vortex_lit(datum: &Datum, file_field: &DataField) ->
Option<Expression> {
- use crate::spec::DataType as PaimonDataType;
- use vortex::array::dtype::Nullability;
- use vortex::array::scalar::{PValue, Scalar, ScalarValue};
- match datum {
- Datum::Bool(v) => Some(lit(*v)),
- Datum::TinyInt(v) => Some(lit(*v)),
- Datum::SmallInt(v) => Some(lit(*v)),
- Datum::Int(v) => Some(lit(*v)),
- Datum::Long(v) => Some(lit(*v)),
- Datum::Float(v) => Some(lit(*v)),
- Datum::Double(v) => Some(lit(*v)),
- Datum::String(v) => Some(lit(v.as_str())),
- Datum::Bytes(v) => Some(lit(v.as_slice())),
- // Date: stored as days since epoch (i32) in both Paimon and Vortex.
- Datum::Date(v) => {
- use vortex::extension::datetime::{Date, TimeUnit};
- let dtype =
- DType::Extension(Date::new(TimeUnit::Days,
Nullability::NonNullable).erased());
- let scalar =
- Scalar::try_new(dtype,
Some(ScalarValue::Primitive(PValue::I32(*v)))).ok()?;
- Some(lit(scalar))
+fn combine_filter_masks(left: &BooleanArray, right: &BooleanArray, use_or:
bool) -> BooleanArray {
+ debug_assert_eq!(left.len(), right.len());
+ boolean_mask_from_predicate(left.len(), |row_index| {
+ if use_or {
+ left.value(row_index) || right.value(row_index)
+ } else {
+ left.value(row_index) && right.value(row_index)
}
- // Time: stored as milliseconds since midnight (i32) in Paimon.
- Datum::Time(v) => {
- use vortex::extension::datetime::{Time, TimeUnit};
- let dtype = DType::Extension(
- Time::new(TimeUnit::Milliseconds,
Nullability::NonNullable).erased(),
- );
- let scalar =
- Scalar::try_new(dtype,
Some(ScalarValue::Primitive(PValue::I32(*v)))).ok()?;
- Some(lit(scalar))
+ })
+}
+
+fn boolean_mask_from_predicate(
+ len: usize,
+ mut predicate: impl FnMut(usize) -> bool,
+) -> BooleanArray {
+ BooleanArray::from((0..len).map(&mut predicate).collect::<Vec<_>>())
+}
+
+fn literal_scalar_for_arrow_filter(
+ literal: &Datum,
+ file_data_type: &DataType,
+) -> crate::Result<Option<Scalar<ArrowArrayRef>>> {
+ let array: ArrowArrayRef = match file_data_type {
+ DataType::Boolean(_) => match literal {
+ Datum::Bool(value) =>
Arc::new(BooleanArray::new_scalar(*value).into_inner()),
+ _ => return Ok(None),
+ },
+ DataType::TinyInt(_) => {
+ match integer_literal(literal).and_then(|value|
i8::try_from(value).ok()) {
+ Some(value) =>
Arc::new(Int8Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ }
}
- // Timestamp: convert (millis, nanos) to the unit matching the field
precision.
- // precision 0-3 → milliseconds, 4-6 → microseconds, 7-9 →
nanoseconds.
- Datum::Timestamp { millis, nanos } => {
- use vortex::extension::datetime::Timestamp;
- let precision = match file_field.data_type() {
- PaimonDataType::Timestamp(ts) => ts.precision(),
- _ => return None,
- };
- let (time_unit, value) = precision_to_time_unit_and_value(*millis,
*nanos, precision);
- let dtype =
- DType::Extension(Timestamp::new(time_unit,
Nullability::NonNullable).erased());
- let scalar =
- Scalar::try_new(dtype,
Some(ScalarValue::Primitive(PValue::I64(value)))).ok()?;
- Some(lit(scalar))
+ DataType::SmallInt(_) => {
+ match integer_literal(literal).and_then(|value|
i16::try_from(value).ok()) {
+ Some(value) =>
Arc::new(Int16Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ }
}
- Datum::LocalZonedTimestamp { millis, nanos } => {
- use vortex::extension::datetime::Timestamp;
- let precision = match file_field.data_type() {
- PaimonDataType::LocalZonedTimestamp(ts) => ts.precision(),
- _ => return None,
- };
- let (time_unit, value) = precision_to_time_unit_and_value(*millis,
*nanos, precision);
- let dtype = DType::Extension(
- Timestamp::new_with_tz(time_unit, Some(Arc::from("UTC")),
Nullability::NonNullable)
- .erased(),
- );
- let scalar =
- Scalar::try_new(dtype,
Some(ScalarValue::Primitive(PValue::I64(value)))).ok()?;
- Some(lit(scalar))
+ DataType::Int(_) => {
+ match integer_literal(literal).and_then(|value|
i32::try_from(value).ok()) {
+ Some(value) =>
Arc::new(Int32Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ }
}
- // Decimal: construct a Vortex Scalar with the correct precision and
scale.
- Datum::Decimal {
- unscaled,
- precision,
- scale,
- } => {
- use vortex::array::dtype::DecimalDType;
- use vortex::array::scalar::{DecimalValue, ScalarValue as SV};
- let precision = u8::try_from(*precision).ok()?;
- let scale = i8::try_from(*scale).ok()?;
- let dtype = DType::Decimal(
- DecimalDType::new(precision, scale),
- Nullability::NonNullable,
- );
- let scalar =
- Scalar::try_new(dtype,
Some(SV::Decimal(DecimalValue::I128(*unscaled)))).ok()?;
- Some(lit(scalar))
+ DataType::BigInt(_) => {
+ match integer_literal(literal).and_then(|value|
i64::try_from(value).ok()) {
+ Some(value) =>
Arc::new(Int64Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ }
}
- }
+ DataType::Float(_) => match float32_literal(literal) {
+ Some(value) =>
Arc::new(Float32Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ },
+ DataType::Double(_) => match float64_literal(literal) {
+ Some(value) =>
Arc::new(Float64Array::new_scalar(value).into_inner()),
+ None => return Ok(None),
+ },
+ DataType::Char(_) | DataType::VarChar(_) => match literal {
+ Datum::String(value) =>
Arc::new(StringArray::new_scalar(value.as_str()).into_inner()),
+ _ => return Ok(None),
+ },
+ DataType::Binary(_) | DataType::VarBinary(_) | DataType::Blob(_) =>
match literal {
+ Datum::Bytes(value) =>
Arc::new(BinaryArray::new_scalar(value.as_slice()).into_inner()),
+ _ => return Ok(None),
+ },
+ DataType::Date(_) => match literal {
+ Datum::Date(value) =>
Arc::new(Date32Array::new_scalar(*value).into_inner()),
+ _ => return Ok(None),
+ },
+ DataType::Time(_) => match literal {
+ Datum::Time(value) =>
Arc::new(Time32MillisecondArray::new_scalar(*value).into_inner()),
+ _ => return Ok(None),
+ },
+ DataType::Timestamp(ts) => match literal {
+ Datum::Timestamp { millis, nanos } => {
+ let Some(array) = timestamp_scalar(*millis, *nanos,
ts.precision(), None)? else {
+ return Ok(None);
+ };
+ array
+ }
+ _ => return Ok(None),
+ },
+ DataType::LocalZonedTimestamp(ts) => match literal {
+ Datum::LocalZonedTimestamp { millis, nanos } => {
+ let Some(array) = timestamp_scalar(*millis, *nanos,
ts.precision(), Some("UTC"))?
+ else {
+ return Ok(None);
+ };
+ array
+ }
+ _ => return Ok(None),
+ },
+ DataType::Decimal(decimal) => match literal {
+ Datum::Decimal {
+ unscaled,
+ precision,
+ scale,
+ } if *precision <= decimal.precision() && *scale ==
decimal.scale() => {
+ let precision =
+ u8::try_from(decimal.precision()).map_err(|_|
Error::Unsupported {
+ message: "Decimal precision exceeds Arrow decimal128
range".to_string(),
+ })?;
+ let scale =
+ i8::try_from(decimal.scale() as i32).map_err(|_|
Error::Unsupported {
+ message: "Decimal scale exceeds Arrow decimal128
range".to_string(),
+ })?;
+ Arc::new(
+ Decimal128Array::new_scalar(*unscaled)
+ .into_inner()
+ .with_precision_and_scale(precision, scale)
+ .map_err(|e| Error::UnexpectedError {
+ message: format!(
+ "Failed to build decimal scalar for Vortex row
filter: {e}"
+ ),
+ source: Some(Box::new(e)),
+ })?,
+ )
+ }
+ _ => return Ok(None),
+ },
+ DataType::Array(_) | DataType::Map(_) | DataType::Multiset(_) |
DataType::Row(_) => {
+ return Ok(None);
+ }
+ };
+
+ Ok(Some(Scalar::new(array)))
}
-/// Convert Paimon's (millis, sub-millis nanos) pair to the Vortex TimeUnit
and i64 storage value
-/// for the given timestamp precision.
-fn precision_to_time_unit_and_value(
+fn timestamp_scalar(
millis: i64,
nanos: i32,
precision: u32,
-) -> (vortex::extension::datetime::TimeUnit, i64) {
- use vortex::extension::datetime::TimeUnit;
- match precision {
- 0..=3 => (TimeUnit::Milliseconds, millis),
- 4..=6 => (
- TimeUnit::Microseconds,
- millis * 1_000 + (nanos as i64) / 1_000,
- ),
- _ => (TimeUnit::Nanoseconds, millis * 1_000_000 + (nanos as i64)),
+ timezone: Option<&'static str>,
+) -> crate::Result<Option<ArrowArrayRef>> {
+ let array: ArrowArrayRef = match precision {
+ 0..=3 => {
+ let array =
TimestampMillisecondArray::new_scalar(millis).into_inner();
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 4..=6 => {
+ let value = millis * 1_000 + (nanos as i64) / 1_000;
+ let array =
TimestampMicrosecondArray::new_scalar(value).into_inner();
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 7..=9 => {
+ let value = millis * 1_000_000 + (nanos as i64);
+ let array =
TimestampNanosecondArray::new_scalar(value).into_inner();
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ _ => return Ok(None),
+ };
+ Ok(Some(array))
+}
+
+fn integer_literal(literal: &Datum) -> Option<i128> {
+ match literal {
+ Datum::TinyInt(value) => Some(i128::from(*value)),
+ Datum::SmallInt(value) => Some(i128::from(*value)),
+ Datum::Int(value) => Some(i128::from(*value)),
+ Datum::Long(value) => Some(i128::from(*value)),
+ _ => None,
+ }
+}
+
+fn float32_literal(literal: &Datum) -> Option<f32> {
+ match literal {
+ Datum::Float(value) => Some(*value),
+ Datum::Double(value) => {
+ let casted = *value as f32;
+ ((casted as f64) == *value).then_some(casted)
+ }
+ _ => None,
+ }
+}
+
+fn float64_literal(literal: &Datum) -> Option<f64> {
+ match literal {
+ Datum::Float(value) => Some(f64::from(*value)),
+ Datum::Double(value) => Some(*value),
+ _ => None,
}
}
@@ -559,97 +877,39 @@ fn vortex_array_to_record_batch(
})
}
-// ---------------------------------------------------------------------------
-// VortexWrite adapter
-// ---------------------------------------------------------------------------
-
-/// Adapts paimon's `AsyncFileWrite` (tokio AsyncWrite) to Vortex's
`VortexWrite`,
-/// with an `AtomicU64` counter tracking bytes flushed to storage.
-struct CountingPaimonWrite {
- inner: Box<dyn crate::io::AsyncFileWrite>,
- bytes_written: Arc<AtomicU64>,
-}
-
-impl VortexWrite for CountingPaimonWrite {
- async fn write_all<B: IoBuf>(&mut self, buffer: B) -> std::io::Result<B> {
- let len = buffer.as_slice().len() as u64;
- tokio::io::AsyncWriteExt::write_all(&mut self.inner,
buffer.as_slice()).await?;
- self.bytes_written.fetch_add(len, Ordering::Relaxed);
- Ok(buffer)
- }
-
- async fn flush(&mut self) -> std::io::Result<()> {
- tokio::io::AsyncWriteExt::flush(&mut self.inner).await
- }
-
- async fn shutdown(&mut self) -> std::io::Result<()> {
- tokio::io::AsyncWriteExt::shutdown(&mut self.inner).await
- }
-}
-
// ---------------------------------------------------------------------------
// VortexFormatWriter
// ---------------------------------------------------------------------------
/// Vortex implementation of [`FormatFileWriter`].
///
-/// Uses a background task with a channel for streaming writes:
-/// - `write()` converts each RecordBatch to a Vortex ArrayRef and sends it
through a channel
-/// - A background `tokio::spawn` task runs `VortexWriteOptions::write()`
consuming the channel
-/// - `close()` drops the sender (signaling EOF) and awaits the background task
-///
-/// This avoids buffering all data in memory and provides accurate
`num_bytes()`.
+/// `write()` converts each RecordBatch to a Vortex ArrayRef. `close()` then
+/// writes all accumulated arrays through Vortex into an in-memory buffer
before
+/// flushing that buffer to Paimon's output file.
pub(crate) struct VortexFormatWriter {
- /// Channel sender for pushing arrays to the background write task.
- sender: Option<kanal::AsyncSender<VortexResult<ArrayRef>>>,
- /// Background write task handle.
- write_task:
Option<tokio::task::JoinHandle<VortexResult<vortex::file::WriteSummary>>>,
- /// Bytes already flushed to storage (updated by the background task).
+ /// Vortex dtype derived from the target Arrow schema.
+ dtype: DType,
+ /// Converted arrays pending final Vortex write.
+ arrays: Vec<ArrayRef>,
+ /// Paimon output file receiving the finalized Vortex buffer.
+ output: OutputFile,
+ /// Bytes already flushed to storage.
bytes_written: Arc<AtomicU64>,
- /// Keeps the Vortex runtime handle alive while the write task uses the
session.
- _runtime: Arc<PaimonVortexRuntime>,
+ /// Estimated bytes staged in `arrays` before Vortex finalizes the file.
+ staged_bytes: usize,
}
impl VortexFormatWriter {
pub(crate) async fn new(output: &OutputFile, schema: SchemaRef) ->
crate::Result<Self> {
- let (session, runtime) = new_vortex_session()?;
let dtype = DType::from_arrow(schema);
-
- // Create channel for streaming arrays to the background writer.
- let (sender, receiver) =
kanal::bounded_async::<VortexResult<ArrayRef>>(1);
-
- // Wrap receiver as an ArrayStream.
- use vortex::io::kanal_ext::KanalExt;
- let array_stream = ArrayStreamAdapter::new(dtype,
receiver.into_stream());
- let sendable_stream = ArrayStreamExt::boxed(array_stream);
-
- // Create the counting VortexWrite sink.
- let async_writer = output.async_writer().await?;
let bytes_written = Arc::new(AtomicU64::new(0));
- let sink = CountingPaimonWrite {
- inner: async_writer,
- bytes_written: Arc::clone(&bytes_written),
- };
-
- // Spawn the background write task.
- let write_task = tokio::spawn(async move {
- let mut sink = sink;
- let result = session
- .write_options()
- .write(&mut sink, sendable_stream)
- .await;
- // Vortex only calls flush(), but opendal needs shutdown() to
finalize the file.
- sink.shutdown()
- .await
- .map_err(|e| vortex::error::vortex_err!("shutdown error:
{e}"))?;
- result
- });
Ok(Self {
- sender: Some(sender),
- write_task: Some(write_task),
+ dtype,
+ arrays: Vec::new(),
+ output: output.clone(),
bytes_written,
- _runtime: runtime,
+ staged_bytes: 0,
})
}
}
@@ -657,90 +917,102 @@ impl VortexFormatWriter {
#[async_trait]
impl FormatFileWriter for VortexFormatWriter {
async fn write(&mut self, batch: &RecordBatch) -> crate::Result<()> {
+ let staged_bytes = batch.get_array_memory_size();
let vortex_arr =
ArrayRef::from_arrow(batch.clone(), false).map_err(|e|
Error::DataInvalid {
message: format!("Failed to convert RecordBatch to Vortex:
{e}"),
source: None,
})?;
- let sender = self.sender.as_ref().ok_or_else(|| Error::DataInvalid {
- message: "VortexFormatWriter already closed".to_string(),
- source: None,
- })?;
-
- if sender.send(Ok(vortex_arr)).await.is_err() {
- // Channel closed — the background task has exited. Try to
retrieve the real error.
- if let Some(task) = self.write_task.take() {
- match task.await {
- Ok(Err(e)) => {
- return Err(Error::DataInvalid {
- message: format!("Vortex background write task
failed: {e}"),
- source: None,
- });
- }
- Err(e) => {
- return Err(Error::DataInvalid {
- message: format!("Vortex background write task
panicked: {e}"),
- source: None,
- });
- }
- Ok(Ok(_)) => {}
- }
- }
- return Err(Error::DataInvalid {
- message: "Vortex background write task exited
unexpectedly".to_string(),
- source: None,
- });
- }
+ self.arrays.push(vortex_arr);
+ self.staged_bytes = self.staged_bytes.saturating_add(staged_bytes);
Ok(())
}
fn num_bytes(&self) -> usize {
- self.bytes_written.load(Ordering::Relaxed) as usize
+ let bytes_written = self.bytes_written.load(Ordering::Relaxed) as
usize;
+ bytes_written.max(self.staged_bytes)
}
fn in_progress_size(&self) -> usize {
- // Vortex manages its own internal buffering in the background task;
- // we have no visibility into it, so report 0.
- 0
+ self.staged_bytes
}
async fn flush(&mut self) -> crate::Result<()> {
- // Vortex handles flushing internally in the background task.
+ // Vortex writes are finalized in close().
Ok(())
}
- async fn close(mut self: Box<Self>) -> crate::Result<u64> {
- // Drop the sender to signal EOF to the background stream.
- drop(self.sender.take());
+ async fn close(self: Box<Self>) -> crate::Result<u64> {
+ let this = *self;
+ let VortexFormatWriter {
+ dtype,
+ arrays,
+ output,
+ bytes_written,
+ staged_bytes: _,
+ } = this;
- // Await the background write task.
- let task = self.write_task.take().ok_or_else(|| Error::DataInvalid {
- message: "VortexFormatWriter already closed".to_string(),
- source: None,
- })?;
+ let (size, buffer) = {
+ let _permit = acquire_vortex_io_permit().await?;
+ tokio::task::spawn_blocking(move ||
write_vortex_buffer_blocking(dtype, arrays))
+ .await
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Vortex write task failed: {e}"),
+ source: None,
+ })??
+ };
+ output.write(bytes::Bytes::from(buffer)).await?;
+ bytes_written.store(size, Ordering::Relaxed);
- let summary = task
- .await
- .map_err(|e| Error::DataInvalid {
- message: format!("Vortex write task panicked: {e}"),
- source: None,
- })?
+ Ok(size)
+ }
+}
+
+fn write_vortex_buffer_blocking(
+ dtype: DType,
+ arrays: Vec<ArrayRef>,
+) -> crate::Result<(u64, Vec<u8>)> {
+ run_vortex_on_thread("paimon-vortex-write", move || {
+ let runtime = CurrentThreadRuntime::new();
+ let session = VortexSession::default().with_handle(runtime.handle());
+ let mut buffer = Vec::new();
+ let summary = runtime
+ .block_on(async {
+ let mut writer = session.write_options().writer(&mut buffer,
dtype);
+ for array in arrays {
+ writer.push(array).await?;
+ }
+ writer.finish().await
+ })
.map_err(|e| Error::DataInvalid {
message: format!("Failed to write Vortex file: {e}"),
source: None,
})?;
- Ok(summary.size())
- }
+ Ok((summary.size(), buffer))
+ })
}
-impl Drop for VortexFormatWriter {
- fn drop(&mut self) {
- if let Some(task) = self.write_task.take() {
- task.abort();
- }
- }
+fn run_vortex_on_thread<T>(
+ name: &'static str,
+ f: impl FnOnce() -> crate::Result<T> + Send + 'static,
+) -> crate::Result<T>
+where
+ T: Send + 'static,
+{
+ let join = std::thread::Builder::new()
+ .name(name.to_string())
+ .spawn(f)
+ .map_err(|e| Error::DataInvalid {
+ message: format!("Failed to spawn Vortex worker thread: {e}"),
+ source: None,
+ })?;
+
+ join.join().map_err(|_| Error::DataInvalid {
+ message: "Vortex worker thread panicked".to_string(),
+ source: None,
+ })?
}
#[cfg(test)]
@@ -751,6 +1023,9 @@ mod tests {
use crate::spec::{DataField, DataType, VarCharType};
use arrow_array::{Int32Array, StringArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
+ use bytes::Bytes;
+ use futures::StreamExt;
+ use std::ops::Range;
fn test_arrow_schema() -> Arc<ArrowSchema> {
Arc::new(ArrowSchema::new(vec![
@@ -770,6 +1045,110 @@ mod tests {
.unwrap()
}
+ struct WholeFileOnlyRead {
+ bytes: Bytes,
+ }
+
+ #[async_trait]
+ impl FileRead for WholeFileOnlyRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ let file_size = self.bytes.len() as u64;
+ if range != (0..file_size) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "expected a whole-file read, got {}..{}",
+ range.start, range.end
+ ),
+ source: None,
+ });
+ }
+ Ok(self.bytes.clone())
+ }
+ }
+
+ #[test]
+ fn test_vortex_writer_outlives_calling_tokio_runtime() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/test_vortex_writer_runtime.vortex";
+ let output = file_io.new_output(path).unwrap();
+ let schema = test_arrow_schema();
+
+ let caller_runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ let writer = caller_runtime.block_on(async {
+ let mut writer = VortexFormatWriter::new(&output, schema.clone())
+ .await
+ .unwrap();
+ let batch = test_batch(&schema, vec![1, 2, 3], vec![10, 20, 30]);
+ writer.write(&batch).await.unwrap();
+ writer
+ });
+ drop(caller_runtime);
+
+ let verifier_runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ let bytes = verifier_runtime
+ .block_on(async { Box::new(writer).close().await })
+ .unwrap();
+ assert!(bytes > 0);
+ }
+
+ #[test]
+ fn test_vortex_reader_stream_outlives_calling_tokio_runtime() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/test_vortex_reader_runtime.vortex";
+ let output = file_io.new_output(path).unwrap();
+ let schema = test_arrow_schema();
+
+ let caller_runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ let stream = caller_runtime.block_on(async {
+ let mut writer = VortexFormatWriter::new(&output, schema.clone())
+ .await
+ .unwrap();
+ let batch = test_batch(&schema, vec![1, 2, 3], vec![10, 20, 30]);
+ writer.write(&batch).await.unwrap();
+ Box::new(writer).close().await.unwrap();
+
+ let input = file_io.new_input(path).unwrap();
+ let file_reader = input.reader().await.unwrap();
+ let metadata = input.metadata().await.unwrap();
+ let reader = VortexFormatReader;
+ reader
+ .read_batch_stream(
+ Box::new(file_reader),
+ metadata.size,
+ &test_file_fields(),
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap()
+ });
+ drop(caller_runtime);
+
+ let verifier_runtime = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .unwrap();
+ let rows = verifier_runtime.block_on(async {
+ let mut stream = stream;
+ let mut rows = 0;
+ while let Some(result) = stream.next().await {
+ rows += result.unwrap().num_rows();
+ }
+ rows
+ });
+ assert_eq!(rows, 3);
+ }
+
#[tokio::test]
async fn test_vortex_writer_write_and_read() {
let file_io = FileIOBuilder::new("memory").build().unwrap();
@@ -1077,182 +1456,86 @@ mod tests {
assert_eq!(total_rows, 5);
}
- // -----------------------------------------------------------------------
- // Predicate conversion unit tests
- // -----------------------------------------------------------------------
-
- use crate::spec::{DataType as PaimonDataType, IntType, PredicateBuilder};
-
- fn test_file_fields() -> Vec<DataField> {
- vec![
- DataField::new(0, "id".to_string(),
PaimonDataType::Int(IntType::new())),
- DataField::new(1, "value".to_string(),
PaimonDataType::Int(IntType::new())),
- ]
- }
-
- #[test]
- fn test_predicate_eq_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder.equal("id", Datum::Int(3)).unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
-
- #[test]
- fn test_predicate_not_eq_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder.not_equal("value", Datum::Int(10)).unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
-
- #[test]
- fn test_predicate_lt_gt_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let lt_pred = builder.less_than("id", Datum::Int(5)).unwrap();
- let gt_pred = builder.greater_than("value", Datum::Int(20)).unwrap();
- let expr = predicates_to_vortex_expr(&[lt_pred, gt_pred], &fields);
- assert!(expr.is_some(), "AND of Lt and Gt should convert");
- }
-
- #[test]
- fn test_predicate_is_null_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder.is_null("id").unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
-
- #[test]
- fn test_predicate_is_not_null_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder.is_not_null("value").unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
-
- #[test]
- fn test_predicate_in_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder
- .is_in("id", vec![Datum::Int(1), Datum::Int(3)])
- .unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
-
- #[test]
- fn test_predicate_not_in_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let pred = builder
- .is_not_in("id", vec![Datum::Int(2), Datum::Int(4)])
- .unwrap();
- let expr = predicates_to_vortex_expr(&[pred], &fields);
- assert!(expr.is_some());
- }
+ #[tokio::test]
+ async fn test_vortex_reader_opens_from_whole_file_buffer() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/test_vortex_whole_file_buffer.vortex";
+ let output = file_io.new_output(path).unwrap();
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("name", ArrowDataType::Utf8, false),
+ ]));
+ let ids: Vec<i32> = (0..10_000).collect();
+ let names: Vec<String> = ids
+ .iter()
+ .map(|id|
format!("row-{id:05}-abcdefghijklmnopqrstuvwxyz0123456789"))
+ .collect();
- #[test]
- fn test_predicate_in_with_unsupported_literal_skips_entirely() {
- let fields = test_file_fields();
- // Manually build an In predicate with a Decimal literal whose
precision
- // exceeds u8 range, making it unconvertible to Vortex.
- let pred = Predicate::Leaf {
- column: "id".to_string(),
- index: 0,
- data_type: PaimonDataType::Int(IntType::new()),
- op: PredicateOperator::In,
- literals: vec![
- Datum::Int(1),
- Datum::Decimal {
- unscaled: 100,
- precision: 256,
- scale: 2,
- },
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ VortexFormatWriter::new(&output, schema.clone())
+ .await
+ .unwrap(),
+ );
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(ids.clone())),
+ Arc::new(StringArray::from(names)),
],
- };
- // The entire In should be skipped (None) because one literal can't
convert.
- let expr = predicate_to_vortex_expr(&pred, &fields);
- assert!(expr.is_none());
- }
+ )
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ writer.close().await.unwrap();
- #[test]
- fn test_predicate_or_with_unsupported_branch_skips_entirely() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let convertible = builder.equal("id", Datum::Int(1)).unwrap();
- // Build an unconvertible leaf (Decimal with precision > u8::MAX).
- let unconvertible = Predicate::Leaf {
- column: "id".to_string(),
- index: 0,
- data_type: PaimonDataType::Int(IntType::new()),
- op: PredicateOperator::Eq,
- literals: vec![Datum::Decimal {
- unscaled: 100,
- precision: 256,
- scale: 2,
- }],
- };
- let or_pred = Predicate::Or(vec![convertible, unconvertible]);
- // The entire OR should be skipped because one branch can't convert.
- let expr = predicate_to_vortex_expr(&or_pred, &fields);
- assert!(expr.is_none());
- }
+ let input = file_io.new_input(path).unwrap();
+ let file_bytes = input.read().await.unwrap();
+ let metadata = input.metadata().await.unwrap();
+ assert!(metadata.size > 65_535);
+ let reader = VortexFormatReader;
- #[test]
- fn test_predicate_and_with_unsupported_branch_keeps_convertible() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let convertible = builder.equal("id", Datum::Int(1)).unwrap();
- let unconvertible = Predicate::Leaf {
- column: "id".to_string(),
- index: 0,
- data_type: PaimonDataType::Int(IntType::new()),
- op: PredicateOperator::Eq,
- literals: vec![Datum::Decimal {
- unscaled: 100,
- precision: 256,
- scale: 2,
- }],
- };
- let and_pred = Predicate::And(vec![convertible, unconvertible]);
- // AND should still produce an expression from the convertible branch.
- let expr = predicate_to_vortex_expr(&and_pred, &fields);
- assert!(expr.is_some());
- }
+ let mut stream = reader
+ .read_batch_stream(
+ Box::new(WholeFileOnlyRead { bytes: file_bytes }),
+ metadata.size,
+ &[
+ DataField::new(0, "id".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "name".to_string(),
+ DataType::VarChar(VarCharType::string_type()),
+ ),
+ ],
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
- #[test]
- fn test_predicate_always_true_false() {
- let fields = test_file_fields();
- assert!(predicate_to_vortex_expr(&Predicate::AlwaysTrue,
&fields).is_some());
- assert!(predicate_to_vortex_expr(&Predicate::AlwaysFalse,
&fields).is_some());
+ let mut all_ids = Vec::new();
+ while let Some(result) = stream.next().await {
+ let batch = result.unwrap();
+ let id_col = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ all_ids.extend(id_col.values().iter().copied());
+ }
+ assert_eq!(all_ids, ids);
}
- #[test]
- fn test_predicate_not_converts() {
- let fields = test_file_fields();
- let builder = PredicateBuilder::new(&fields);
- let inner = builder.equal("id", Datum::Int(3)).unwrap();
- let pred = Predicate::Not(Box::new(inner));
- let expr = predicate_to_vortex_expr(&pred, &fields);
- assert!(expr.is_some());
- }
+ use crate::spec::{DataType as PaimonDataType, IntType, PredicateBuilder};
- #[test]
- fn test_empty_predicates_returns_none() {
- let fields = test_file_fields();
- let expr = predicates_to_vortex_expr(&[], &fields);
- assert!(expr.is_none());
+ fn test_file_fields() -> Vec<DataField> {
+ vec![
+ DataField::new(0, "id".to_string(),
PaimonDataType::Int(IntType::new())),
+ DataField::new(1, "value".to_string(),
PaimonDataType::Int(IntType::new())),
+ ]
}
// -----------------------------------------------------------------------
- // Integration tests: predicate pushdown through VortexFormatReader
+ // Integration tests: Arrow-side predicate filtering through
VortexFormatReader
// -----------------------------------------------------------------------
/// Helper: write test data and read back with given predicates, return
collected id values.
@@ -1389,121 +1672,171 @@ mod tests {
assert!(ids.is_empty());
}
- // -----------------------------------------------------------------------
- // Timestamp precision tests
- // -----------------------------------------------------------------------
-
- use crate::spec::{LocalZonedTimestampType, TimestampType};
+ #[tokio::test]
+ async fn test_vortex_read_filter_column_not_projected() {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/test_vortex_pred_unprojected.vortex";
+ let output = file_io.new_output(path).unwrap();
+ let schema = test_arrow_schema();
- #[test]
- fn test_precision_to_time_unit_and_value_millis() {
- use vortex::extension::datetime::TimeUnit;
- // precision 0-3 → millis, nanos ignored
- assert_eq!(
- precision_to_time_unit_and_value(1000, 500_000, 0),
- (TimeUnit::Milliseconds, 1000)
- );
- assert_eq!(
- precision_to_time_unit_and_value(1000, 500_000, 3),
- (TimeUnit::Milliseconds, 1000)
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ VortexFormatWriter::new(&output, schema.clone())
+ .await
+ .unwrap(),
);
- }
+ writer
+ .write(&test_batch(
+ &schema,
+ vec![1, 2, 3, 4, 5],
+ vec![10, 20, 30, 40, 50],
+ ))
+ .await
+ .unwrap();
+ writer.close().await.unwrap();
- #[test]
- fn test_precision_to_time_unit_and_value_micros() {
- use vortex::extension::datetime::TimeUnit;
- // precision 4-6 → micros = millis * 1000 + nanos / 1000
- // 1000ms, 500_000ns (= 500µs) → 1_000_500µs
- assert_eq!(
- precision_to_time_unit_and_value(1000, 500_000, 6),
- (TimeUnit::Microseconds, 1_000_500)
- );
- assert_eq!(
- precision_to_time_unit_and_value(1000, 0, 4),
- (TimeUnit::Microseconds, 1_000_000)
- );
- }
+ let fields = test_file_fields();
+ let builder = PredicateBuilder::new(&fields);
+ let pred = builder.greater_than("value", Datum::Int(30)).unwrap();
+ let fp = FilePredicates {
+ predicates: vec![pred],
+ file_fields: fields.clone(),
+ };
+ let read_fields = vec![fields[0].clone()];
- #[test]
- fn test_precision_to_time_unit_and_value_nanos() {
- use vortex::extension::datetime::TimeUnit;
- // precision 7-9 → nanos = millis * 1_000_000 + nanos
- // 1000ms, 500_000ns → 1_000_500_000ns
- assert_eq!(
- precision_to_time_unit_and_value(1000, 500_000, 9),
- (TimeUnit::Nanoseconds, 1_000_500_000)
- );
- assert_eq!(
- precision_to_time_unit_and_value(1000, 0, 7),
- (TimeUnit::Nanoseconds, 1_000_000_000)
- );
- }
+ let input = file_io.new_input(path).unwrap();
+ let file_reader = input.reader().await.unwrap();
+ let metadata = input.metadata().await.unwrap();
+ let reader = VortexFormatReader;
+ let mut stream = reader
+ .read_batch_stream(
+ Box::new(file_reader),
+ metadata.size,
+ &read_fields,
+ Some(&fp),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
- #[test]
- fn test_datum_to_vortex_lit_timestamp_precision() {
- let ts_field_millis = DataField::new(
- 0,
- "ts".to_string(),
- PaimonDataType::Timestamp(TimestampType::new(3).unwrap()),
- );
- let ts_field_micros = DataField::new(
- 0,
- "ts".to_string(),
- PaimonDataType::Timestamp(TimestampType::new(6).unwrap()),
- );
- let ts_field_nanos = DataField::new(
- 0,
- "ts".to_string(),
- PaimonDataType::Timestamp(TimestampType::new(9).unwrap()),
- );
+ let mut all_ids = Vec::new();
+ while let Some(result) = stream.next().await {
+ let batch = result.unwrap();
+ assert_eq!(batch.num_columns(), 1);
+ let id_col = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ all_ids.extend(id_col.values().iter().copied());
+ }
+ assert_eq!(all_ids, vec![4, 5]);
+ }
- let datum = Datum::Timestamp {
- millis: 1000,
- nanos: 500_000,
+ #[tokio::test]
+ async fn
test_vortex_empty_projection_with_predicate_returns_filtered_count() {
+ let fields = test_file_fields();
+ let builder = PredicateBuilder::new(&fields);
+ let pred = builder.greater_than("id", Datum::Int(3)).unwrap();
+ let fp = FilePredicates {
+ predicates: vec![pred],
+ file_fields: fields,
};
- // All should produce Some
- assert!(datum_to_vortex_lit(&datum, &ts_field_millis).is_some());
- assert!(datum_to_vortex_lit(&datum, &ts_field_micros).is_some());
- assert!(datum_to_vortex_lit(&datum, &ts_field_nanos).is_some());
+ let count = write_and_read_empty_projection_with_predicates(
+ "memory:/test_vortex_empty_proj_pred.vortex",
+ Some(fp),
+ )
+ .await;
+ assert_eq!(count, 2);
}
- #[test]
- fn test_datum_to_vortex_lit_local_zoned_timestamp_precision() {
- let field_millis = DataField::new(
- 0,
- "ts".to_string(),
-
PaimonDataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap()),
- );
- let field_micros = DataField::new(
- 0,
- "ts".to_string(),
-
PaimonDataType::LocalZonedTimestamp(LocalZonedTimestampType::new(6).unwrap()),
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn test_vortex_predicate_reads_do_not_block_each_other() {
+ let fields = test_file_fields();
+ let builder = PredicateBuilder::new(&fields);
+ let eq = FilePredicates {
+ predicates: vec![builder.equal("id", Datum::Int(3)).unwrap()],
+ file_fields: fields.clone(),
+ };
+ let gt = FilePredicates {
+ predicates: vec![builder.greater_than("id",
Datum::Int(3)).unwrap()],
+ file_fields: fields.clone(),
+ };
+ let combined = FilePredicates {
+ predicates: vec![
+ builder.greater_or_equal("id", Datum::Int(2)).unwrap(),
+ builder.less_than("value", Datum::Int(50)).unwrap(),
+ ],
+ file_fields: fields,
+ };
+
+ let (empty, eq, gt, combined) = tokio::join!(
+
write_and_read_empty_projection("memory:/test_vortex_concurrent_empty.vortex"),
+
write_and_read_with_predicates("memory:/test_vortex_concurrent_eq.vortex",
Some(eq)),
+
write_and_read_with_predicates("memory:/test_vortex_concurrent_gt.vortex",
Some(gt)),
+ write_and_read_with_predicates(
+ "memory:/test_vortex_concurrent_combined.vortex",
+ Some(combined)
+ ),
);
- let datum = Datum::LocalZonedTimestamp {
- millis: 2000,
- nanos: 123_456,
- };
+ assert_eq!(empty, 5);
+ assert_eq!(eq, vec![3]);
+ assert_eq!(gt, vec![4, 5]);
+ assert_eq!(combined, vec![2, 3, 4]);
+ }
- assert!(datum_to_vortex_lit(&datum, &field_millis).is_some());
- assert!(datum_to_vortex_lit(&datum, &field_micros).is_some());
+ async fn write_and_read_empty_projection(path: &str) -> usize {
+ write_and_read_empty_projection_with_predicates(path, None).await
}
- #[test]
- fn test_datum_to_vortex_lit_timestamp_wrong_field_type_returns_none() {
- // Timestamp datum with an Int field should return None
- let int_field = DataField::new(0, "id".to_string(),
PaimonDataType::Int(IntType::new()));
- let datum = Datum::Timestamp {
- millis: 1000,
- nanos: 0,
- };
- assert!(datum_to_vortex_lit(&datum, &int_field).is_none());
+ async fn write_and_read_empty_projection_with_predicates(
+ path: &str,
+ predicates: Option<FilePredicates>,
+ ) -> usize {
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let output = file_io.new_output(path).unwrap();
+ let schema = test_arrow_schema();
- let datum_lz = Datum::LocalZonedTimestamp {
- millis: 1000,
- nanos: 0,
- };
- assert!(datum_to_vortex_lit(&datum_lz, &int_field).is_none());
+ let mut writer: Box<dyn FormatFileWriter> = Box::new(
+ VortexFormatWriter::new(&output, schema.clone())
+ .await
+ .unwrap(),
+ );
+ writer
+ .write(&test_batch(
+ &schema,
+ vec![1, 2, 3, 4, 5],
+ vec![10, 20, 30, 40, 50],
+ ))
+ .await
+ .unwrap();
+ writer.close().await.unwrap();
+
+ let input = file_io.new_input(path).unwrap();
+ let file_reader = input.reader().await.unwrap();
+ let metadata = input.metadata().await.unwrap();
+
+ let reader = VortexFormatReader;
+ let mut stream = reader
+ .read_batch_stream(
+ Box::new(file_reader),
+ metadata.size,
+ &[],
+ predicates.as_ref(),
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+
+ let mut total_rows = 0;
+ while let Some(result) = stream.next().await {
+ let batch = result.unwrap();
+ assert_eq!(batch.num_columns(), 0);
+ total_rows += batch.num_rows();
+ }
+ total_rows
}
}
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 66846e7..e5b62e4 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -1547,6 +1547,48 @@ mod tests {
assert_eq!(total_rows, 4);
}
+ #[cfg(feature = "vortex")]
+ #[tokio::test]
+ async fn test_vortex_write_rolling_on_target_file_size() {
+ let file_io = test_file_io();
+ let table_path = "memory:/test_vortex_table_write_rolling";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("target-file-size", "1b")
+ .option("file.format", "vortex")
+ .build()
+ .unwrap();
+ let table_schema = TableSchema::new(0, &schema);
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "test_table"),
+ table_path.to_string(),
+ table_schema,
+ None,
+ );
+
+ let mut table_write = TableWrite::new(&table,
"test-user".to_string()).unwrap();
+
+ table_write
+ .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+ .await
+ .unwrap();
+ table_write
+ .write_arrow_batch(&make_batch(vec![3, 4], vec![30, 40]))
+ .await
+ .unwrap();
+
+ let messages = table_write.prepare_commit().await.unwrap();
+ assert_eq!(messages.len(), 1);
+ assert_eq!(messages[0].new_files.len(), 2);
+
+ let total_rows: i64 = messages[0].new_files.iter().map(|f|
f.row_count).sum();
+ assert_eq!(total_rows, 4);
+ }
+
// -----------------------------------------------------------------------
// Primary-key table write tests
// -----------------------------------------------------------------------