blob: a7804460eacca0a81af9097ebce758220ac2b2d2 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::create_format_reader;
use crate::arrow::schema_evolution::{create_index_mapping, NULL_FIELD_INDEX};
use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
use crate::io::FileIO;
use crate::spec::{DataField, DataFileMeta, Predicate};
use crate::table::schema_manager::SchemaManager;
use crate::table::ArrowRecordBatchStream;
use crate::table::RowRange;
use crate::{DataSplit, Error};
use arrow_array::{Array, Int64Array, RecordBatch};
use arrow_cast::cast;
use async_stream::try_stream;
use futures::StreamExt;
use std::sync::Arc;
/// Reads data from Parquet files.
#[derive(Clone)]
pub(crate) struct DataFileReader {
file_io: FileIO,
schema_manager: SchemaManager,
table_schema_id: i64,
table_fields: Vec<DataField>,
read_type: Vec<DataField>,
predicates: Vec<Predicate>,
blob_as_descriptor: bool,
}
impl DataFileReader {
pub(crate) fn new(
file_io: FileIO,
schema_manager: SchemaManager,
table_schema_id: i64,
table_fields: Vec<DataField>,
read_type: Vec<DataField>,
predicates: Vec<Predicate>,
) -> Self {
Self {
file_io,
schema_manager,
table_schema_id,
table_fields,
read_type,
predicates,
blob_as_descriptor: false,
}
}
pub(crate) fn with_blob_as_descriptor(mut self, blob_as_descriptor: bool) -> Self {
self.blob_as_descriptor = blob_as_descriptor;
self
}
/// Take a stream of DataSplits and read every data file in each split.
/// Returns a stream of Arrow RecordBatches from all files.
///
/// Uses SchemaManager to load the data file's schema (via `DataFileMeta.schema_id`)
/// and computes field-ID-based index mapping for schema evolution (added columns,
/// type promotion, column reordering).
///
/// Matches [RawFileSplitRead.createReader](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java).
pub fn read(self, data_splits: &[DataSplit]) -> crate::Result<ArrowRecordBatchStream> {
let splits: Vec<DataSplit> = data_splits.to_vec();
let reader = self;
Ok(try_stream! {
for split in splits {
// Create DV factory for this split only.
let dv_factory = if split
.data_deletion_files()
.is_some_and(|files| files.iter().any(Option::is_some))
{
Some(
DeletionVectorFactory::new(
&reader.file_io,
split.data_files(),
split.data_deletion_files(),
)
.await?,
)
} else {
None
};
for file_meta in split.data_files().to_vec() {
let dv = dv_factory
.as_ref()
.and_then(|factory| factory.get_deletion_vector(&file_meta.file_name))
.cloned();
// Load data file's schema if it differs from the table schema.
let data_fields: Option<Vec<DataField>> = if file_meta.schema_id != reader.table_schema_id {
let data_schema = reader.schema_manager.schema(file_meta.schema_id).await?;
Some(data_schema.fields().to_vec())
} else {
None
};
let mut stream = reader.read_single_file_stream(
&split,
file_meta,
data_fields,
dv,
None,
)?;
while let Some(batch) = stream.next().await {
yield batch?;
}
}
}
}
.boxed())
}
/// Read a single parquet file from a split, returning a lazy stream of batches.
/// Optionally applies a deletion vector.
///
/// Handles schema evolution using field-ID-based index mapping:
/// - `data_fields`: if `Some`, the fields from the data file's schema (loaded via SchemaManager).
/// Used to compute index mapping between `read_type` and data fields by field ID.
/// - Columns missing from the file are filled with null arrays.
/// - Columns whose Arrow type differs from the target type are cast (type promotion).
///
/// Reference: [RawFileSplitRead.createFileReader](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/operation/RawFileSplitRead.java)
pub(super) fn read_single_file_stream(
&self,
split: &DataSplit,
file_meta: DataFileMeta,
data_fields: Option<Vec<DataField>>,
dv: Option<Arc<DeletionVector>>,
row_ranges: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
let read_type = self.read_type.clone();
let table_fields = self.table_fields.clone();
let predicates = self.predicates.clone();
let file_io = self.file_io.clone();
let split = split.clone();
let blob_as_descriptor = self.blob_as_descriptor;
let target_schema = build_target_arrow_schema(&read_type)?;
let file_fields = data_fields.clone().unwrap_or_else(|| table_fields.clone());
let is_row_file = is_row_file(&file_meta);
// Compute index mapping and determine which columns to read from the file.
let (projected_read_fields, index_mapping) = if let Some(ref df) = data_fields {
let mapping = create_index_mapping(&read_type, df);
match mapping {
Some(ref idx_map) => {
let mut seen = std::collections::HashSet::new();
let fields_to_read: Vec<DataField> = idx_map
.iter()
.filter(|&&idx| idx != NULL_FIELD_INDEX && seen.insert(idx))
.map(|&idx| df[idx as usize].clone())
.collect();
(fields_to_read, Some(idx_map.clone()))
}
None => (df.clone(), None),
}
} else {
(read_type.clone(), None)
};
let format_read_fields = if is_row_file {
file_fields.clone()
} else {
projected_read_fields
};
// Remap predicates from table-level to file-level indices.
let file_predicates = {
let remapped = crate::arrow::filtering::remap_predicates_to_file(
&predicates,
&table_fields,
&file_fields,
);
if remapped.is_empty() {
None
} else {
Some(crate::arrow::format::FilePredicates {
predicates: remapped,
file_fields: file_fields.clone(),
})
}
};
Ok(try_stream! {
let path_to_read = split.data_file_path(&file_meta);
let format_reader = create_format_reader(&path_to_read, blob_as_descriptor)?;
let input_file = file_io.new_input(&path_to_read)?;
let file_reader = input_file.reader().await?;
let local_ranges = row_ranges.as_ref().map(|ranges| {
to_local_row_ranges(ranges, file_meta.first_row_id.unwrap_or(0), file_meta.row_count)
});
let row_selection = merge_row_selection(
file_meta.row_count,
dv.as_deref(),
local_ranges.as_deref(),
);
let mut batch_stream = format_reader.read_batch_stream(
Box::new(file_reader),
file_meta.file_size as u64,
&format_read_fields,
file_predicates.as_ref(),
None,
row_selection,
).await?;
while let Some(batch) = batch_stream.next().await {
let batch = batch?;
let num_rows = batch.num_rows();
let batch_schema = batch.schema();
// Build output columns using index mapping (field-ID-based) or by name.
let mut columns: Vec<Arc<dyn arrow_array::Array>> = Vec::with_capacity(target_schema.fields().len());
for (i, target_field) in target_schema.fields().iter().enumerate() {
let source_col = if let Some(ref idx_map) = index_mapping {
let data_idx = idx_map[i];
if data_idx == NULL_FIELD_INDEX {
None
} else {
let data_field = &data_fields.as_ref().unwrap()[data_idx as usize];
batch_schema
.index_of(data_field.name())
.ok()
.map(|col_idx| batch.column(col_idx))
}
} else if let Some(ref df) = data_fields {
batch_schema
.index_of(df[i].name())
.ok()
.map(|col_idx| batch.column(col_idx))
} else {
batch_schema
.index_of(target_field.name())
.ok()
.map(|col_idx| batch.column(col_idx))
};
match source_col {
Some(col) => {
if col.data_type() == target_field.data_type() {
columns.push(col.clone());
} else {
let casted = cast(col, target_field.data_type()).map_err(|e| {
Error::UnexpectedError {
message: format!(
"Failed to cast column '{}' from {:?} to {:?}: {e}",
target_field.name(),
col.data_type(),
target_field.data_type()
),
source: Some(Box::new(e)),
}
})?;
columns.push(casted);
}
}
None => {
let null_array = arrow_array::new_null_array(target_field.data_type(), num_rows);
columns.push(null_array);
}
}
}
let result = if columns.is_empty() {
RecordBatch::try_new_with_options(
target_schema.clone(),
columns,
&arrow_array::RecordBatchOptions::new().with_row_count(Some(num_rows)),
)
} else {
RecordBatch::try_new(target_schema.clone(), columns)
}
.map_err(|e| {
Error::UnexpectedError {
message: format!("Failed to build schema-evolved RecordBatch: {e}"),
source: Some(Box::new(e)),
}
})?;
yield result;
}
}
.boxed())
}
}
fn is_row_file(file_meta: &DataFileMeta) -> bool {
file_meta.file_name.to_ascii_lowercase().ends_with(".row")
|| file_meta
.external_path
.as_deref()
.is_some_and(|path| path.to_ascii_lowercase().ends_with(".row"))
}
/// Convert absolute RowRanges to file-local 0-based ranges.
fn to_local_row_ranges(
row_ranges: &[RowRange],
first_row_id: i64,
row_count: i64,
) -> Vec<RowRange> {
let file_end = first_row_id + row_count - 1;
row_ranges
.iter()
.filter_map(|r| {
if r.to() < first_row_id || r.from() > file_end {
return None;
}
let local_from = (r.from() - first_row_id).max(0);
let local_to = (r.to() - first_row_id).min(row_count - 1);
Some(RowRange::new(local_from, local_to))
})
.collect()
}
/// Merge DV and row_ranges into a unified list of 0-based inclusive RowRanges.
/// Returns `None` if no filtering is needed (no DV and no ranges).
///
/// Complexity: O(D + R) where D = number of deleted rows, R = number of ranges.
fn merge_row_selection(
row_count: i64,
dv: Option<&DeletionVector>,
row_ranges: Option<&[RowRange]>,
) -> Option<Vec<RowRange>> {
let has_dv = dv.is_some_and(|d| !d.is_empty());
let has_ranges = row_ranges.is_some();
if !has_dv && !has_ranges {
return None;
}
if !has_dv {
return row_ranges.map(|r| r.to_vec());
}
let dv_ranges = dv_to_non_deleted_ranges(dv.unwrap(), row_count);
match row_ranges {
Some(ranges) => Some(intersect_sorted_ranges(&dv_ranges, ranges)),
None => Some(dv_ranges),
}
}
/// Convert a DeletionVector into sorted non-deleted inclusive RowRanges.
fn dv_to_non_deleted_ranges(dv: &DeletionVector, row_count: i64) -> Vec<RowRange> {
let mut result = Vec::new();
let mut cursor: i64 = 0;
for deleted in dv.iter() {
let del = deleted as i64;
if del >= row_count {
break;
}
if del > cursor {
result.push(RowRange::new(cursor, del - 1));
}
cursor = del + 1;
}
if cursor < row_count {
result.push(RowRange::new(cursor, row_count - 1));
}
result
}
/// Intersect two sorted lists of inclusive RowRanges using a merge-style scan.
fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) -> Vec<RowRange> {
let mut result = Vec::new();
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
let from = a[i].from().max(b[j].from());
let to = a[i].to().min(b[j].to());
if from <= to {
result.push(RowRange::new(from, to));
}
if a[i].to() < b[j].to() {
i += 1;
} else {
j += 1;
}
}
result
}
/// Expand row_ranges into a flat sequence of selected row IDs for a file.
/// Intended for per-batch _ROW_ID attachment — callers should not pass
/// whole-file ranges with millions of rows, as this allocates a Vec<i64>
/// proportional to the selected range size.
pub(super) fn expand_selected_row_ids(
first_row_id: i64,
row_count: i64,
row_ranges: &[RowRange],
) -> Vec<i64> {
if row_count == 0 {
return Vec::new();
}
let file_end = first_row_id + row_count - 1;
let mut ids = Vec::new();
for r in row_ranges {
let from = r.from().max(first_row_id);
let to = r.to().min(file_end);
for id in from..=to {
ids.push(id);
}
}
ids
}
pub(super) fn attach_row_id(
batch: RecordBatch,
row_id_index: usize,
selected_row_ids: &[i64],
row_id_offset: &mut usize,
output_schema: &Arc<arrow_schema::Schema>,
) -> crate::Result<RecordBatch> {
let num_rows = batch.num_rows();
let end = *row_id_offset + num_rows;
if end > selected_row_ids.len() {
return Err(Error::UnexpectedError {
message: format!(
"Row ID offset out of bounds: need {}..{} but selected_row_ids has {} entries",
*row_id_offset,
end,
selected_row_ids.len()
),
source: None,
});
}
let batch_ids = &selected_row_ids[*row_id_offset..end];
*row_id_offset = end;
let array: Arc<dyn arrow_array::Array> = Arc::new(Int64Array::from(batch_ids.to_vec()));
insert_column_at(batch, array, row_id_index, output_schema)
}
pub(super) fn insert_column_at(
batch: RecordBatch,
column: Arc<dyn arrow_array::Array>,
insert_index: usize,
output_schema: &Arc<arrow_schema::Schema>,
) -> crate::Result<RecordBatch> {
let mut columns: Vec<Arc<dyn arrow_array::Array>> = Vec::with_capacity(batch.num_columns() + 1);
for (i, col) in batch.columns().iter().enumerate() {
if i == insert_index {
columns.push(column.clone());
}
columns.push(col.clone());
}
if insert_index >= batch.num_columns() {
columns.push(column);
}
RecordBatch::try_new(output_schema.clone(), columns).map_err(|e| Error::UnexpectedError {
message: format!("Failed to insert column into RecordBatch: {e}"),
source: Some(Box::new(e)),
})
}
/// Append a null `_ROW_ID` column for files without `first_row_id`.
pub(super) fn append_null_row_id_column(
batch: RecordBatch,
insert_index: usize,
output_schema: &Arc<arrow_schema::Schema>,
) -> crate::Result<RecordBatch> {
let array: Arc<dyn arrow_array::Array> = Arc::new(Int64Array::new_null(batch.num_rows()));
insert_column_at(batch, array, insert_index, output_schema)
}
#[cfg(test)]
mod row_tests {
use super::*;
use crate::arrow::build_target_arrow_schema;
use crate::arrow::format::create_format_writer;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{BinaryRow, DataFileMeta, DataType, IntType, VarCharType};
use crate::table::source::DataSplitBuilder;
use arrow_array::{Int32Array, StringArray};
use futures::TryStreamExt;
fn field(id: i32, name: &str, data_type: DataType) -> DataField {
DataField::new(id, name.to_string(), data_type)
}
fn data_file(file_name: &str, file_size: i64, row_count: i64, schema_id: i64) -> DataFileMeta {
DataFileMeta {
file_name: file_name.to_string(),
file_size,
row_count,
min_key: Vec::new(),
max_key: Vec::new(),
key_stats: BinaryTableStats::empty(),
value_stats: BinaryTableStats::empty(),
min_sequence_number: 0,
max_sequence_number: 0,
schema_id,
level: 0,
extra_files: Vec::new(),
creation_time: None,
delete_row_count: None,
embedded_index: None,
file_source: None,
value_stats_cols: None,
external_path: None,
first_row_id: None,
write_cols: None,
}
}
#[tokio::test]
async fn row_projection_reads_full_file_schema_before_projecting() {
let fields = vec![
field(0, "id", DataType::Int(IntType::new())),
field(1, "name", DataType::VarChar(VarCharType::string_type())),
field(2, "score", DataType::Int(IntType::new())),
];
let schema = build_target_arrow_schema(&fields).unwrap();
let batch = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
Arc::new(Int32Array::from(vec![10, 20, 30])),
],
)
.unwrap();
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/row_projection";
let bucket_path = format!("{table_path}/bucket-0");
let file_name = "part-0.row";
let file_path = format!("{bucket_path}/{file_name}");
let output = file_io.new_output(&file_path).unwrap();
let mut writer = create_format_writer(&output, schema, "zstd", 1, None, None)
.await
.unwrap();
writer.write(&batch).await.unwrap();
let file_size = writer.close().await.unwrap() as i64;
let schema_id = 1;
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
.with_data_files(vec![data_file(file_name, file_size, 3, schema_id)])
.build()
.unwrap();
let read_type = vec![fields[2].clone()];
let reader = DataFileReader::new(
file_io.clone(),
SchemaManager::new(file_io, table_path.to_string()),
schema_id,
fields,
read_type,
Vec::new(),
);
let batches = reader
.read(&[split])
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].num_columns(), 1);
assert_eq!(batches[0].schema().field(0).name(), "score");
let scores = batches[0]
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
assert_eq!(scores.values(), &[10, 20, 30]);
}
}
#[cfg(all(test, feature = "mosaic"))]
mod tests {
use super::*;
use crate::arrow::build_target_arrow_schema;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
ArrayType, DataFileMeta, DataType, Datum, IntType, Predicate, PredicateBuilder, VarCharType,
};
use crate::table::source::{DataSplitBuilder, DeletionFile};
use arrow_array::{Int32Array, StringArray};
use bytes::Bytes;
use futures::TryStreamExt;
use paimon_mosaic_core::spec::COMPRESSION_NONE;
use paimon_mosaic_core::writer::{MosaicWriter, OutputFile, WriterOptions};
use roaring::RoaringBitmap;
use std::io;
struct MemOutputFile {
data: Vec<u8>,
}
impl MemOutputFile {
fn new() -> Self {
Self { data: Vec::new() }
}
}
impl OutputFile for MemOutputFile {
fn write(&mut self, data: &[u8]) -> io::Result<()> {
self.data.extend_from_slice(data);
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn pos(&self) -> u64 {
self.data.len() as u64
}
}
fn data_field(id: i32, name: &str, data_type: DataType) -> DataField {
DataField::new(id, name.to_string(), data_type)
}
fn data_file(file_name: &str, file_size: i64, row_count: i64, schema_id: i64) -> DataFileMeta {
DataFileMeta {
file_name: file_name.to_string(),
file_size,
row_count,
min_key: Vec::new(),
max_key: Vec::new(),
key_stats: BinaryTableStats::empty(),
value_stats: BinaryTableStats::empty(),
min_sequence_number: 0,
max_sequence_number: 0,
schema_id,
level: 0,
extra_files: Vec::new(),
creation_time: None,
delete_row_count: None,
embedded_index: None,
file_source: None,
value_stats_cols: None,
external_path: None,
first_row_id: None,
write_cols: None,
}
}
fn write_mosaic(batch: &RecordBatch) -> Bytes {
let out = MemOutputFile::new();
let mut writer = MosaicWriter::new(
out,
batch.schema().as_ref(),
WriterOptions {
compression: COMPRESSION_NONE,
num_buckets: 2,
row_group_max_size: u64::MAX,
..Default::default()
},
)
.unwrap();
writer.write_batch(batch).unwrap();
writer.close().unwrap();
Bytes::from(writer.output().data.to_vec())
}
#[tokio::test]
async fn test_mosaic_physical_missing_column_is_null_filled() {
let physical_fields = vec![
data_field(0, "id", DataType::Int(IntType::with_nullable(false))),
data_field(
1,
"name",
DataType::VarChar(VarCharType::with_nullable(true, 20).unwrap()),
),
];
let read_fields = vec![
physical_fields[0].clone(),
data_field(
2,
"items",
DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
),
physical_fields[1].clone(),
];
let physical_arrow_schema = build_target_arrow_schema(&physical_fields).unwrap();
let batch = RecordBatch::try_new(
physical_arrow_schema,
vec![
Arc::new(Int32Array::from(vec![1, 2, 3])),
Arc::new(StringArray::from(vec!["a", "b", "c"])),
],
)
.unwrap();
let data = write_mosaic(&batch);
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/mosaic_schema_evolution";
let bucket_path = format!("{table_path}/bucket-0");
let file_name = "part-0.mosaic";
let file_path = format!("{bucket_path}/{file_name}");
file_io
.new_output(&file_path)
.unwrap()
.write(data.clone())
.await
.unwrap();
let table_schema_id = 1;
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(crate::spec::BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
.with_data_files(vec![data_file(
file_name,
data.len() as i64,
3,
table_schema_id,
)])
.build()
.unwrap();
let schema_manager = SchemaManager::new(file_io.clone(), table_path.to_string());
let reader = DataFileReader::new(
file_io,
schema_manager,
table_schema_id,
read_fields.clone(),
read_fields.clone(),
Vec::new(),
);
let stream = reader.read(&[split]).unwrap();
let batches = stream.try_collect::<Vec<_>>().await.unwrap();
assert_eq!(batches.len(), 1);
let result = &batches[0];
assert_eq!(result.num_rows(), 3);
assert_eq!(result.num_columns(), 3);
assert_eq!(result.schema().field(0).name(), "id");
assert_eq!(result.schema().field(1).name(), "items");
assert_eq!(result.schema().field(2).name(), "name");
assert_eq!(result.column(1).null_count(), 3);
let ids = result
.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap();
assert_eq!(ids.values(), &[1, 2, 3]);
let names = result
.column(2)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert_eq!(names.value(0), "a");
assert_eq!(names.value(2), "c");
}
fn pk_fields() -> Vec<DataField> {
vec![
data_field(0, "id", DataType::Int(IntType::with_nullable(false))),
data_field(
1,
"name",
DataType::VarChar(VarCharType::with_nullable(true, 20).unwrap()),
),
]
}
fn pk_batch(ids: Vec<i32>, names: Vec<&str>) -> RecordBatch {
RecordBatch::try_new(
build_target_arrow_schema(&pk_fields()).unwrap(),
vec![
Arc::new(Int32Array::from(ids)),
Arc::new(StringArray::from(names)),
],
)
.unwrap()
}
fn write_multi_row_group_mosaic(batches: &[RecordBatch], stats_columns: Vec<String>) -> Bytes {
let out = MemOutputFile::new();
let mut writer = MosaicWriter::new(
out,
batches[0].schema().as_ref(),
WriterOptions {
compression: COMPRESSION_NONE,
num_buckets: 2,
// One row group per written batch, so each batch carries its own stats.
row_group_max_size: 1,
stats_columns,
..Default::default()
},
)
.unwrap();
for batch in batches {
writer.write_batch(batch).unwrap();
}
writer.close().unwrap();
Bytes::from(writer.output().data.to_vec())
}
fn write_parquet(batch: &RecordBatch) -> Bytes {
let mut buf = Vec::new();
let mut writer =
parquet::arrow::ArrowWriter::try_new(&mut buf, batch.schema(), None).unwrap();
writer.write(batch).unwrap();
writer.close().unwrap();
Bytes::from(buf)
}
/// Writes a Paimon deletion-vector blob and returns the `DeletionFile` pointing at it.
/// Layout matches [`DeletionVector::read_from_bytes`]:
/// `i32 bitmapLength (magic + bitmap) | i32 magic | bitmap bytes | i32 crc`.
async fn write_deletion_file(
file_io: &crate::io::FileIO,
path: &str,
deleted_rows: &[u32],
) -> DeletionFile {
// BitmapDeletionVector.MAGIC_NUMBER, see crate::deletion_vector.
const MAGIC_NUMBER: i32 = 1581511376;
let mut bitmap = RoaringBitmap::new();
for row in deleted_rows {
bitmap.insert(*row);
}
let mut bitmap_bytes = Vec::new();
bitmap.serialize_into(&mut bitmap_bytes).unwrap();
let bitmap_length = 4 + bitmap_bytes.len() as i32;
let mut blob = Vec::new();
blob.extend_from_slice(&bitmap_length.to_be_bytes());
blob.extend_from_slice(&MAGIC_NUMBER.to_be_bytes());
blob.extend_from_slice(&bitmap_bytes);
blob.extend_from_slice(&0i32.to_be_bytes()); // crc, skipped on read
file_io
.new_output(path)
.unwrap()
.write(Bytes::from(blob))
.await
.unwrap();
DeletionFile::new(
path.to_string(),
0,
bitmap_length as i64,
Some(deleted_rows.len() as i64),
)
}
fn collect_ids(batches: &[RecordBatch]) -> Vec<i32> {
batches
.iter()
.flat_map(|b| {
b.column(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec()
})
.collect()
}
/// Deletion vectors are applied format-agnostically by `DataFileReader`; verify a
/// Mosaic file honors deleted rows end to end.
#[tokio::test]
async fn test_mosaic_with_deletion_vector() {
let fields = pk_fields();
let data = write_mosaic(&pk_batch(vec![1, 2, 3], vec!["a", "b", "c"]));
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/mosaic_dv";
let bucket_path = format!("{table_path}/bucket-0");
let file_name = "part-0.mosaic";
file_io
.new_output(&format!("{bucket_path}/{file_name}"))
.unwrap()
.write(data.clone())
.await
.unwrap();
// Delete row index 1 (id = 2).
let dv = write_deletion_file(&file_io, &format!("{table_path}/index/dv-0"), &[1]).await;
let table_schema_id = 1;
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(crate::spec::BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
.with_data_files(vec![data_file(
file_name,
data.len() as i64,
3,
table_schema_id,
)])
.with_data_deletion_files(vec![Some(dv)])
.build()
.unwrap();
let schema_manager = SchemaManager::new(file_io.clone(), table_path.to_string());
let reader = DataFileReader::new(
file_io,
schema_manager,
table_schema_id,
fields.clone(),
fields.clone(),
Vec::new(),
);
let batches = reader
.read(&[split])
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(collect_ids(&batches), vec![1, 3]);
}
/// A Mosaic file and a Parquet file in the same split must both be read and concatenated.
#[tokio::test]
async fn test_mosaic_mixed_format_read() {
let fields = pk_fields();
let mosaic_data = write_mosaic(&pk_batch(vec![1, 2], vec!["a", "b"]));
let parquet_data = write_parquet(&pk_batch(vec![3, 4], vec!["c", "d"]));
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/mosaic_mixed";
let bucket_path = format!("{table_path}/bucket-0");
for (name, data) in [
("part-0.mosaic", &mosaic_data),
("part-1.parquet", &parquet_data),
] {
file_io
.new_output(&format!("{bucket_path}/{name}"))
.unwrap()
.write(data.clone())
.await
.unwrap();
}
let table_schema_id = 1;
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(crate::spec::BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
.with_data_files(vec![
data_file(
"part-0.mosaic",
mosaic_data.len() as i64,
2,
table_schema_id,
),
data_file(
"part-1.parquet",
parquet_data.len() as i64,
2,
table_schema_id,
),
])
.build()
.unwrap();
let schema_manager = SchemaManager::new(file_io.clone(), table_path.to_string());
let reader = DataFileReader::new(
file_io,
schema_manager,
table_schema_id,
fields.clone(),
fields.clone(),
Vec::new(),
);
let batches = reader
.read(&[split])
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut ids = collect_ids(&batches);
ids.sort_unstable();
assert_eq!(ids, vec![1, 2, 3, 4]);
}
/// Row-group predicate pruning, deletion vectors and projection must compose correctly:
/// the predicate keeps one row group, the DV deletes one of its rows, projection keeps `id`.
#[tokio::test]
async fn test_mosaic_predicate_dv_projection_combination() {
let fields = pk_fields();
let data = write_multi_row_group_mosaic(
&[
pk_batch(vec![1, 2], vec!["a", "b"]),
pk_batch(vec![10, 11], vec!["c", "d"]),
pk_batch(vec![20, 21], vec!["e", "f"]),
],
vec!["id".to_string()],
);
let file_io = FileIOBuilder::new("memory").build().unwrap();
let table_path = "memory:/mosaic_combo";
let bucket_path = format!("{table_path}/bucket-0");
let file_name = "part-0.mosaic";
file_io
.new_output(&format!("{bucket_path}/{file_name}"))
.unwrap()
.write(data.clone())
.await
.unwrap();
// Delete global row index 2 (id = 10, first row of the second row group).
let dv = write_deletion_file(&file_io, &format!("{table_path}/index/dv-0"), &[2]).await;
let table_schema_id = 1;
let split = DataSplitBuilder::new()
.with_snapshot(1)
.with_partition(crate::spec::BinaryRow::new(0))
.with_bucket(0)
.with_bucket_path(bucket_path)
.with_total_buckets(1)
.with_data_files(vec![data_file(
file_name,
data.len() as i64,
6,
table_schema_id,
)])
.with_data_deletion_files(vec![Some(dv)])
.build()
.unwrap();
let predicate: Predicate = PredicateBuilder::new(&fields)
.equal("id", Datum::Int(10))
.unwrap();
let read_type = vec![fields[0].clone()];
let schema_manager = SchemaManager::new(file_io.clone(), table_path.to_string());
let reader = DataFileReader::new(
file_io,
schema_manager,
table_schema_id,
fields.clone(),
read_type,
vec![predicate],
);
let batches = reader
.read(&[split])
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(batches.iter().map(|b| b.num_columns()).max(), Some(1));
assert_eq!(collect_ids(&batches), vec![11]);
}
}