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 db2d1ad6 feat(file_index): integrate pruning into raw data-file reads
(#783)
db2d1ad6 is described below
commit db2d1ad6caee3d53ea7e9c2caabcc9ef4cd304d1
Author: QuakeWang <[email protected]>
AuthorDate: Sat Sep 5 07:11:24 2026 +0800
feat(file_index): integrate pruning into raw data-file reads (#783)
---
crates/paimon/src/file_index/evaluator.rs | 640 ++++++++++++++++++++
crates/paimon/src/file_index/file_index_format.rs | 175 ++++--
crates/paimon/src/file_index/file_index_reader.rs | 2 +-
.../paimon/src/file_index/file_indexer_factory.rs | 4 +
crates/paimon/src/file_index/mod.rs | 3 +-
crates/paimon/src/spec/core_options.rs | 21 +
crates/paimon/src/table/data_file_reader.rs | 665 ++++++++++++++++++++-
crates/paimon/src/table/table_read.rs | 111 +++-
8 files changed, 1551 insertions(+), 70 deletions(-)
diff --git a/crates/paimon/src/file_index/evaluator.rs
b/crates/paimon/src/file_index/evaluator.rs
new file mode 100644
index 00000000..ce84aa75
--- /dev/null
+++ b/crates/paimon/src/file_index/evaluator.rs
@@ -0,0 +1,640 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashSet;
+
+use bytes::Bytes;
+
+use crate::file_index::file_index_predicate::FileIndexPredicate;
+use crate::file_index::file_index_result::FileIndexResult;
+use crate::file_index::FileIndexFormatReader;
+use crate::io::FileIO;
+use crate::spec::{DataField, DataFileMeta, DataType, Datum, Predicate,
DATA_FILE_INDEX_SUFFIX};
+use crate::Error;
+
+/// Evaluate the usable data predicates against one data file's FileIndex.
+pub(crate) async fn evaluate_file_index(
+ file_io: &FileIO,
+ bucket_path: &str,
+ file: &DataFileMeta,
+ table_fields: &[DataField],
+ data_fields: &[DataField],
+ predicates: &[Predicate],
+) -> crate::Result<FileIndexResult> {
+ if predicates.is_empty() ||
!(0..=i64::from(i32::MAX)).contains(&file.row_count) {
+ return Ok(FileIndexResult::Remain);
+ }
+
+ let Some(predicate) = remap_predicates(table_fields, data_fields,
predicates) else {
+ return Ok(FileIndexResult::Remain);
+ };
+ let mut required_columns = HashSet::new();
+ collect_required_columns(&predicate, &mut required_columns);
+ if required_columns.is_empty() {
+ return Ok(FileIndexResult::Remain);
+ }
+
+ let file_index = if let Some(embedded) = &file.embedded_index {
+
FileIndexFormatReader::get_file_index_from_bytes(Bytes::copy_from_slice(embedded)).await?
+ } else {
+ let sidecars = file
+ .extra_files
+ .iter()
+ .filter(|name| name.ends_with(DATA_FILE_INDEX_SUFFIX))
+ .collect::<Vec<_>>();
+ match sidecars.as_slice() {
+ [] => return Ok(FileIndexResult::Remain),
+ [sidecar] => {
+ let path = file.aligned_file_path(bucket_path, sidecar);
+
FileIndexFormatReader::get_file_index(file_io.new_input(&path)?).await?
+ }
+ _ => {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Found more than one index file for data file '{}':
{}",
+ file.file_name,
+ sidecars
+ .iter()
+ .map(|name| name.as_str())
+ .collect::<Vec<_>>()
+ .join(" and ")
+ ),
+ source: None,
+ })
+ }
+ }
+ };
+
+ let readers = file_index
+ .create_index_readers(data_fields, &required_columns)
+ .await?;
+ if readers.values().all(Vec::is_empty) {
+ return Ok(FileIndexResult::Remain);
+ }
+ Ok(FileIndexPredicate::new(readers).evaluate(&predicate))
+}
+
+fn remap_predicates(
+ table_fields: &[DataField],
+ data_fields: &[DataField],
+ predicates: &[Predicate],
+) -> Option<Predicate> {
+ let remapped = predicates
+ .iter()
+ .filter_map(|predicate| remap_predicate(table_fields, data_fields,
predicate))
+ .collect::<Vec<_>>();
+ (!remapped.is_empty()).then(|| Predicate::and(remapped))
+}
+
+fn remap_predicate(
+ table_fields: &[DataField],
+ data_fields: &[DataField],
+ predicate: &Predicate,
+) -> Option<Predicate> {
+ match predicate {
+ Predicate::Leaf {
+ column,
+ index,
+ data_type,
+ op,
+ literals,
+ } => {
+ let table_field = table_fields.get(*index)?;
+ if table_field.name() != column {
+ return None;
+ }
+ let (data_index, data_field) = data_fields
+ .iter()
+ .enumerate()
+ .find(|(_, field)| field.id() == table_field.id())?;
+ let literals = devolve_literals(data_type, data_field.data_type(),
literals)?;
+ Some(Predicate::Leaf {
+ column: data_field.name().to_string(),
+ index: data_index,
+ data_type: data_field.data_type().clone(),
+ op: *op,
+ literals,
+ })
+ }
+ Predicate::And(children) => {
+ let remapped = children
+ .iter()
+ .filter_map(|child| remap_predicate(table_fields, data_fields,
child))
+ .collect::<Vec<_>>();
+ (!remapped.is_empty()).then(|| Predicate::and(remapped))
+ }
+ Predicate::Or(children) => {
+ let remapped = children
+ .iter()
+ .map(|child| remap_predicate(table_fields, data_fields, child))
+ .collect::<Option<Vec<_>>>()?;
+ Some(Predicate::or(remapped))
+ }
+ Predicate::Not(inner) => {
+ remap_predicate_exact(table_fields, data_fields,
inner).map(Predicate::negate)
+ }
+ Predicate::AlwaysTrue => Some(Predicate::AlwaysTrue),
+ Predicate::AlwaysFalse => Some(Predicate::AlwaysFalse),
+ }
+}
+
+/// Remap only complete subtrees, as required below negation where widening is
unsafe.
+fn remap_predicate_exact(
+ table_fields: &[DataField],
+ data_fields: &[DataField],
+ predicate: &Predicate,
+) -> Option<Predicate> {
+ match predicate {
+ Predicate::And(children) => children
+ .iter()
+ .map(|child| remap_predicate_exact(table_fields, data_fields,
child))
+ .collect::<Option<Vec<_>>>()
+ .map(Predicate::and),
+ Predicate::Or(children) => children
+ .iter()
+ .map(|child| remap_predicate_exact(table_fields, data_fields,
child))
+ .collect::<Option<Vec<_>>>()
+ .map(Predicate::or),
+ Predicate::Not(inner) => {
+ remap_predicate_exact(table_fields, data_fields,
inner).map(Predicate::negate)
+ }
+ Predicate::Leaf { .. } | Predicate::AlwaysTrue |
Predicate::AlwaysFalse => {
+ remap_predicate(table_fields, data_fields, predicate)
+ }
+ }
+}
+
+fn devolve_literals(
+ table_type: &DataType,
+ data_type: &DataType,
+ literals: &[Datum],
+) -> Option<Vec<Datum>> {
+ if same_type_ignoring_nullability(table_type, data_type) {
+ return Some(literals.to_vec());
+ }
+ if !is_integer_type(table_type) || !is_integer_type(data_type) {
+ return None;
+ }
+ literals
+ .iter()
+ .map(|literal| {
+ let value = integer_value(table_type, literal)?;
+ integer_datum(data_type, value)
+ })
+ .collect()
+}
+
+fn same_type_ignoring_nullability(left: &DataType, right: &DataType) -> bool {
+ match (
+ left.copy_with_nullable(true),
+ right.copy_with_nullable(true),
+ ) {
+ (Ok(left), Ok(right)) => left == right,
+ _ => false,
+ }
+}
+
+fn is_integer_type(data_type: &DataType) -> bool {
+ matches!(
+ data_type,
+ DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) |
DataType::BigInt(_)
+ )
+}
+
+fn integer_value(data_type: &DataType, datum: &Datum) -> Option<i64> {
+ match (data_type, datum) {
+ (DataType::TinyInt(_), Datum::TinyInt(value)) =>
Some(i64::from(*value)),
+ (DataType::SmallInt(_), Datum::SmallInt(value)) =>
Some(i64::from(*value)),
+ (DataType::Int(_), Datum::Int(value)) => Some(i64::from(*value)),
+ (DataType::BigInt(_), Datum::Long(value)) => Some(*value),
+ _ => None,
+ }
+}
+
+fn integer_datum(data_type: &DataType, value: i64) -> Option<Datum> {
+ match data_type {
+ DataType::TinyInt(_) => i8::try_from(value).ok().map(Datum::TinyInt),
+ DataType::SmallInt(_) =>
i16::try_from(value).ok().map(Datum::SmallInt),
+ DataType::Int(_) => i32::try_from(value).ok().map(Datum::Int),
+ DataType::BigInt(_) => Some(Datum::Long(value)),
+ _ => None,
+ }
+}
+
+fn collect_required_columns(predicate: &Predicate, columns: &mut
HashSet<String>) {
+ match predicate {
+ Predicate::Leaf { column, .. } => {
+ columns.insert(column.clone());
+ }
+ Predicate::And(children) | Predicate::Or(children) => {
+ for child in children {
+ collect_required_columns(child, columns);
+ }
+ }
+ Predicate::Not(inner) => collect_required_columns(inner, columns),
+ Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+
+ use super::*;
+ use crate::common::Options;
+ use crate::file_index::file_index_format::write_column_indexes;
+ use crate::file_index::file_index_result::FileIndexResult;
+ use crate::file_index::file_indexer_factory::{FileIndexerFactory,
BITMAP_INDEX};
+ use crate::io::FileIOBuilder;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{
+ BigIntType, FloatType, IntType, PredicateBuilder, PredicateOperator,
VarCharType,
+ };
+
+ fn field(id: i32, name: &str, data_type: DataType) -> DataField {
+ DataField::new(id, name.to_string(), data_type)
+ }
+
+ fn data_file(row_count: i64) -> DataFileMeta {
+ DataFileMeta {
+ file_name: "part-0.parquet".to_string(),
+ file_size: 1,
+ 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: 0,
+ 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,
+ column_max_sequence_numbers: None,
+ }
+ }
+
+ async fn bitmap_index_bytes(
+ path: &str,
+ column: &str,
+ data_type: DataType,
+ values: &[Datum],
+ ) -> crate::Result<Bytes> {
+ let mut writer =
+ FileIndexerFactory::create_writer(BITMAP_INDEX, data_type,
&Options::new())?;
+ for value in values {
+ writer.write(Some(value))?;
+ }
+ let indexes = HashMap::from([(
+ column.to_string(),
+ HashMap::from([(BITMAP_INDEX.to_string(),
Some(writer.serialized_bytes()?))]),
+ )]);
+ write_column_indexes(path, indexes)
+ .await?
+ .to_input_file()
+ .read()
+ .await
+ }
+
+ fn assert_int_leaf(
+ predicate: Predicate,
+ expected_column: &str,
+ expected_index: usize,
+ expected_literal: i32,
+ ) {
+ assert!(matches!(
+ predicate,
+ Predicate::Leaf {
+ column,
+ index,
+ data_type: DataType::Int(_),
+ op: PredicateOperator::Eq,
+ literals,
+ } if column == expected_column
+ && index == expected_index
+ && literals == vec![Datum::Int(expected_literal)]
+ ));
+ }
+
+ #[test]
+ fn
test_remap_predicate_uses_field_id_for_rename_reorder_and_integer_devolution() {
+ let table_fields = vec![
+ field(0, "new_id", DataType::BigInt(BigIntType::new())),
+ field(1, "name", DataType::VarChar(VarCharType::new(20).unwrap())),
+ ];
+ let data_fields = vec![
+ table_fields[1].clone(),
+ field(0, "old_id", DataType::Int(IntType::new())),
+ ];
+ let predicate = PredicateBuilder::new(&table_fields)
+ .equal("new_id", Datum::Long(42))
+ .unwrap();
+
+ let remapped = remap_predicate(&table_fields, &data_fields,
&predicate).unwrap();
+
+ assert_int_leaf(remapped, "old_id", 1, 42);
+ }
+
+ #[test]
+ fn test_remap_predicate_falls_back_for_unsafe_schema_changes() {
+ let table_fields = vec![
+ field(0, "id", DataType::BigInt(BigIntType::new())),
+ field(1, "added", DataType::Int(IntType::new())),
+ ];
+ let data_fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let builder = PredicateBuilder::new(&table_fields);
+
+ assert!(remap_predicate(
+ &table_fields,
+ &data_fields,
+ &builder
+ .equal("id", Datum::Long(i64::from(i32::MAX) + 1))
+ .unwrap(),
+ )
+ .is_none());
+ assert!(remap_predicate(
+ &table_fields,
+ &data_fields,
+ &builder.equal("added", Datum::Int(1)).unwrap(),
+ )
+ .is_none());
+
+ let promoted_table = vec![field(0, "id",
DataType::Float(FloatType::new()))];
+ let promoted_predicate = PredicateBuilder::new(&promoted_table)
+ .equal("id", Datum::Float(1.0))
+ .unwrap();
+ assert!(remap_predicate(&promoted_table, &data_fields,
&promoted_predicate).is_none());
+ }
+
+ #[test]
+ fn
test_remap_predicate_keeps_safe_and_child_but_requires_complete_or_and_not() {
+ let table_fields = vec![
+ field(0, "id", DataType::Int(IntType::new())),
+ field(1, "added", DataType::Int(IntType::new())),
+ ];
+ let data_fields = vec![table_fields[0].clone()];
+ let builder = PredicateBuilder::new(&table_fields);
+ let safe = builder.equal("id", Datum::Int(1)).unwrap();
+ let unsafe_predicate = builder.equal("added", Datum::Int(2)).unwrap();
+
+ let remapped_and = remap_predicate(
+ &table_fields,
+ &data_fields,
+ &Predicate::and(vec![safe.clone(), unsafe_predicate.clone()]),
+ )
+ .unwrap();
+ assert_int_leaf(remapped_and, "id", 0, 1);
+ assert!(remap_predicate(
+ &table_fields,
+ &data_fields,
+ &Predicate::or(vec![safe.clone(), unsafe_predicate.clone()]),
+ )
+ .is_none());
+ assert!(remap_predicate(
+ &table_fields,
+ &data_fields,
+ &Predicate::negate(unsafe_predicate.clone()),
+ )
+ .is_none());
+
+ let exact_double_not =
Predicate::Not(Box::new(Predicate::Not(Box::new(safe.clone()))));
+ assert_int_leaf(
+ remap_predicate(&table_fields, &data_fields,
&exact_double_not).unwrap(),
+ "id",
+ 0,
+ 1,
+ );
+
+ let nested_not = Predicate::negate(Predicate::and(vec![
+ Predicate::negate(safe),
+ unsafe_predicate,
+ ]));
+ assert!(remap_predicate(&table_fields, &data_fields,
&nested_not).is_none());
+ }
+
+ #[tokio::test]
+ async fn test_evaluator_prefers_embedded_index_over_ambiguous_sidecars()
-> crate::Result<()> {
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let bytes = bitmap_index_bytes(
+ "memory:/evaluator_embedded_source",
+ "id",
+ fields[0].data_type().clone(),
+ &[Datum::Int(1), Datum::Int(2)],
+ )
+ .await?;
+ let mut file = data_file(2);
+ file.embedded_index = Some(bytes.to_vec());
+ file.extra_files = vec!["first.index".to_string(),
"second.index".to_string()];
+ let predicate = PredicateBuilder::new(&fields).equal("id",
Datum::Int(2))?;
+ let file_io = FileIOBuilder::new("memory").build()?;
+
+ let result = evaluate_file_index(
+ &file_io,
+ "memory:/unused-bucket",
+ &file,
+ &fields,
+ &fields,
+ &[predicate],
+ )
+ .await?;
+
+ assert_eq!(
+ result,
+ FileIndexResult::Selection([1_u32].into_iter().collect())
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_evaluator_resolves_regular_and_external_sidecars() ->
crate::Result<()> {
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let bytes = bitmap_index_bytes(
+ "memory:/evaluator_sidecar_source",
+ "id",
+ fields[0].data_type().clone(),
+ &[Datum::Int(1), Datum::Int(2)],
+ )
+ .await?;
+ let predicate = PredicateBuilder::new(&fields).equal("id",
Datum::Int(1))?;
+ let file_io = FileIOBuilder::new("memory").build()?;
+
+ for (bucket_path, external_path, sidecar_path) in [
+ (
+ "memory:/regular/bucket-0",
+ None,
+ "memory:/regular/bucket-0/part-0.parquet.index",
+ ),
+ (
+ "memory:/ignored/bucket-0",
+ Some("memory:/external/data/part-0.parquet".to_string()),
+ "memory:/external/data/part-0.parquet.index",
+ ),
+ ] {
+ file_io
+ .new_output(sidecar_path)?
+ .write(bytes.clone())
+ .await?;
+ let mut file = data_file(2);
+ file.extra_files = vec!["part-0.parquet.index".to_string()];
+ file.external_path = external_path;
+
+ let result = evaluate_file_index(
+ &file_io,
+ bucket_path,
+ &file,
+ &fields,
+ &fields,
+ std::slice::from_ref(&predicate),
+ )
+ .await?;
+ assert_eq!(
+ result,
+ FileIndexResult::Selection([0_u32].into_iter().collect())
+ );
+ }
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn
test_evaluator_rejects_ambiguous_sidecars_without_embedded_index() ->
crate::Result<()>
+ {
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let predicate = PredicateBuilder::new(&fields).equal("id",
Datum::Int(1))?;
+ let mut file = data_file(1);
+ file.extra_files = vec![
+ "first.index".to_string(),
+ "notes.txt".to_string(),
+ "second.index".to_string(),
+ ];
+ let file_io = FileIOBuilder::new("memory").build()?;
+
+ let error = evaluate_file_index(
+ &file_io,
+ "memory:/bucket-0",
+ &file,
+ &fields,
+ &fields,
+ &[predicate],
+ )
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ Error::DataInvalid { message, .. }
+ if message.contains("first.index") &&
message.contains("second.index")
+ ));
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_evaluator_absent_or_unsupported_index_remains() ->
crate::Result<()> {
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let predicate = PredicateBuilder::new(&fields).equal("id",
Datum::Int(1))?;
+ let file_io = FileIOBuilder::new("memory").build()?;
+ let file = data_file(1);
+ assert_eq!(
+ evaluate_file_index(
+ &file_io,
+ "memory:/bucket-0",
+ &file,
+ &fields,
+ &fields,
+ std::slice::from_ref(&predicate),
+ )
+ .await?,
+ FileIndexResult::Remain
+ );
+
+ let indexes = HashMap::from([(
+ "id".to_string(),
+ HashMap::from([(
+ "range-bitmap".to_string(),
+ Some(Bytes::from_static(b"unsupported payload")),
+ )]),
+ )]);
+ let bytes =
write_column_indexes("memory:/evaluator_unsupported_source", indexes)
+ .await?
+ .to_input_file()
+ .read()
+ .await?;
+ let mut file = data_file(1);
+ file.embedded_index = Some(bytes.to_vec());
+ assert_eq!(
+ evaluate_file_index(
+ &file_io,
+ "memory:/bucket-0",
+ &file,
+ &fields,
+ &fields,
+ &[predicate],
+ )
+ .await?,
+ FileIndexResult::Remain
+ );
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_evaluator_row_count_boundaries_are_conservative() ->
crate::Result<()> {
+ let fields = vec![field(0, "id", DataType::Int(IntType::new()))];
+ let predicate = PredicateBuilder::new(&fields).equal("id",
Datum::Int(1))?;
+ let file_io = FileIOBuilder::new("memory").build()?;
+
+ for row_count in [-1, i64::from(i32::MAX) + 1] {
+ let mut file = data_file(row_count);
+ file.embedded_index = Some(vec![0]);
+ assert_eq!(
+ evaluate_file_index(
+ &file_io,
+ "memory:/bucket-0",
+ &file,
+ &fields,
+ &fields,
+ std::slice::from_ref(&predicate),
+ )
+ .await?,
+ FileIndexResult::Remain
+ );
+ }
+
+ for row_count in [0, i64::from(i32::MAX)] {
+ let mut file = data_file(row_count);
+ file.embedded_index = Some(vec![0]);
+ assert!(matches!(
+ evaluate_file_index(
+ &file_io,
+ "memory:/bucket-0",
+ &file,
+ &fields,
+ &fields,
+ std::slice::from_ref(&predicate),
+ )
+ .await,
+ Err(Error::FileIndexFormatInvalid { .. })
+ ));
+ }
+ Ok(())
+ }
+}
diff --git a/crates/paimon/src/file_index/file_index_format.rs
b/crates/paimon/src/file_index/file_index_format.rs
index f5fcb22a..1e24319b 100644
--- a/crates/paimon/src/file_index/file_index_format.rs
+++ b/crates/paimon/src/file_index/file_index_format.rs
@@ -24,7 +24,7 @@ use crate::{
file_index_reader::{EmptyFileIndexReader, FileIndexReader},
file_indexer_factory::FileIndexerFactory,
},
- io::{FileIO, FileRead, FileStatus, InputFile, OutputFile},
+ io::{FileIO, FileRead, InputFile, OutputFile},
spec::{DataField, DataType},
Error,
};
@@ -364,7 +364,6 @@ pub struct FileIndex {
impl FileIndex {
/// Constructs readers for the required columns described by this
outer-format file.
- #[allow(dead_code)]
pub(crate) async fn create_index_readers(
&self,
fields: &[DataField],
@@ -382,6 +381,9 @@ impl FileIndex {
};
let mut column_readers = Vec::with_capacity(index_info.len());
for (identifier, info) in index_info {
+ if !FileIndexerFactory::is_supported(identifier) {
+ continue;
+ }
if info.start_pos == EMPTY_INDEX_FLAG {
column_readers.push(Box::new(EmptyFileIndexReader) as
Box<dyn FileIndexReader>);
continue;
@@ -468,16 +470,27 @@ impl FileIndex {
pub struct FileIndexFormatReader {
reader: Box<dyn FileRead>,
- stat: FileStatus,
+ file_size: u64,
}
impl FileIndexFormatReader {
pub async fn get_file_index(input_file: InputFile) ->
crate::Result<FileIndex> {
let reader = input_file.reader().await?;
- let mut file_reader = Self {
- reader: Box::new(reader),
- stat: input_file.metadata().await?,
- };
+ let file_size = input_file.metadata().await?.size;
+ Self::get_file_index_from_reader(Box::new(reader), file_size).await
+ }
+
+ pub(crate) async fn get_file_index_from_bytes(bytes: Bytes) ->
crate::Result<FileIndex> {
+ let file_size = u64::try_from(bytes.len())
+ .map_err(|_| format_invalid("embedded file index is too large"))?;
+ Self::get_file_index_from_reader(Box::new(BytesFileRead(bytes)),
file_size).await
+ }
+
+ async fn get_file_index_from_reader(
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+ ) -> crate::Result<FileIndex> {
+ let mut file_reader = Self { reader, file_size };
let header = file_reader.read_header().await?;
Ok(FileIndex {
header,
@@ -486,10 +499,10 @@ impl FileIndexFormatReader {
}
async fn read_header(&mut self) -> crate::Result<HashMap<String,
HashMap<String, IndexInfo>>> {
- if self.stat.size < FIXED_HEADER_LENGTH as u64 {
+ if self.file_size < FIXED_HEADER_LENGTH as u64 {
return Err(format_invalid(format!(
"truncated fixed header: need {FIXED_HEADER_LENGTH} bytes, but
file has {}",
- self.stat.size
+ self.file_size
)));
}
@@ -521,10 +534,10 @@ impl FileIndexFormatReader {
"header length {head_length} is smaller than the minimum
{MIN_HEADER_LENGTH}"
)));
}
- if head_length as u64 > self.stat.size {
+ if head_length as u64 > self.file_size {
return Err(format_invalid(format!(
"header length {head_length} exceeds file size {}",
- self.stat.size
+ self.file_size
)));
}
@@ -548,7 +561,7 @@ impl FileIndexFormatReader {
let index_name = read_java_utf(&mut buffer, "index name")?;
let start_pos = read_i32(&mut buffer, "index start position")?;
let length = read_i32(&mut buffer, "index length")?;
- Self::validate_index_range(start_pos, length, head_length as
u64, self.stat.size)?;
+ Self::validate_index_range(start_pos, length, head_length as
u64, self.file_size)?;
index_info_map.insert(index_name, IndexInfo { start_pos,
length });
}
@@ -624,6 +637,27 @@ impl FileIndexFormatReader {
}
}
+struct BytesFileRead(Bytes);
+
+#[async_trait::async_trait]
+impl FileRead for BytesFileRead {
+ async fn read(&self, range: std::ops::Range<u64>) -> crate::Result<Bytes> {
+ let start = usize::try_from(range.start)
+ .map_err(|_| format_invalid("embedded file index range start is
too large"))?;
+ let end = usize::try_from(range.end)
+ .map_err(|_| format_invalid("embedded file index range end is too
large"))?;
+ if start > end || end > self.0.len() {
+ return Err(format_invalid(format!(
+ "embedded file index range {}..{} exceeds byte length {}",
+ range.start,
+ range.end,
+ self.0.len()
+ )));
+ }
+ Ok(self.0.slice(start..end))
+ }
+}
+
#[cfg(test)]
mod file_index_format_tests {
@@ -784,6 +818,53 @@ mod file_index_format_tests {
Ok(())
}
+ #[tokio::test]
+ async fn test_embedded_bytes_reader_matches_sidecar_reader() ->
crate::Result<()> {
+ let fields = [DataField::new(
+ 0,
+ "a".to_string(),
+ DataType::Int(IntType::new()),
+ )];
+ let mut writer = FileIndexerFactory::create_writer(
+ BITMAP_INDEX,
+ fields[0].data_type().clone(),
+ &Options::new(),
+ )?;
+ for value in [Datum::Int(1), Datum::Int(2), Datum::Int(1)] {
+ writer.write(Some(&value))?;
+ }
+ let indexes = HashMap::from([(
+ "a".to_string(),
+ HashMap::from([(BITMAP_INDEX.to_string(),
Some(writer.serialized_bytes()?))]),
+ )]);
+ let output = write_column_indexes("memory:/tmp/embedded_file_index",
indexes).await?;
+ let bytes = output.clone().to_input_file().read().await?;
+ let sidecar =
FileIndexFormatReader::get_file_index(output.to_input_file()).await?;
+ let embedded =
FileIndexFormatReader::get_file_index_from_bytes(bytes).await?;
+ let required_columns = HashSet::from(["a".to_string()]);
+ let predicate = PredicateBuilder::new(&fields).equal("a",
Datum::Int(1))?;
+
+ let sidecar_result = FileIndexPredicate::new(
+ sidecar
+ .create_index_readers(&fields, &required_columns)
+ .await?,
+ )
+ .evaluate(&predicate);
+ let embedded_result = FileIndexPredicate::new(
+ embedded
+ .create_index_readers(&fields, &required_columns)
+ .await?,
+ )
+ .evaluate(&predicate);
+
+ assert_eq!(embedded_result, sidecar_result);
+ assert_eq!(
+ embedded_result,
+ FileIndexResult::Selection([0_u32, 2].into_iter().collect())
+ );
+ Ok(())
+ }
+
#[tokio::test]
async fn test_writer_matches_java_modified_utf8_bytes() ->
crate::Result<()> {
let indexes = HashMap::from([(
@@ -1098,7 +1179,7 @@ mod file_index_format_tests {
assert_eq!(readers.len(), 3);
assert_eq!(readers["a"].len(), 2);
assert_eq!(readers["b"].len(), 1);
- assert_eq!(readers["empty"].len(), 1);
+ assert!(readers["empty"].is_empty());
let predicate = FileIndexPredicate::new(readers);
let builder = PredicateBuilder::new(&fields);
@@ -1112,7 +1193,7 @@ mod file_index_format_tests {
);
assert_eq!(
predicate.evaluate(&builder.equal("empty", Datum::Int(1))?),
- FileIndexResult::Skip
+ FileIndexResult::Remain
);
Ok(())
@@ -1177,28 +1258,52 @@ mod file_index_format_tests {
}
#[tokio::test]
- async fn test_composition_rejects_unknown_non_empty_identifier() ->
crate::Result<()> {
- let indexes = HashMap::from([(
- "a".to_string(),
- HashMap::from([("unknown".to_string(),
Some(Bytes::from_static(b"payload")))]),
- )]);
- let output = write_column_indexes("memory:/tmp/unknown_file_index",
indexes).await?;
- let file_index =
FileIndexFormatReader::get_file_index(output.to_input_file()).await?;
- let fields = [DataField::new(
- 0,
- "a".to_string(),
- DataType::Int(IntType::new()),
- )];
-
- let required_columns = HashSet::from(["a".to_string()]);
- let error = match file_index
- .create_index_readers(&fields, &required_columns)
- .await
- {
- Ok(_) => panic!("unknown identifier must fail"),
- Err(error) => error,
+ async fn
test_composition_skips_unknown_payload_and_keeps_supported_reader() ->
crate::Result<()>
+ {
+ let data_type = DataType::Int(IntType::new());
+ let mut writer =
+ FileIndexerFactory::create_writer(BITMAP_INDEX, data_type.clone(),
&Options::new())?;
+ writer.write(Some(&Datum::Int(1)))?;
+ let supported_payload = writer.serialized_bytes()?;
+ let unknown_payload = Bytes::from_static(b"must not be read");
+ let supported_end = supported_payload.len() as u64;
+ let mut data = BytesMut::with_capacity(supported_payload.len() +
unknown_payload.len());
+ data.extend_from_slice(&supported_payload);
+ data.extend_from_slice(&unknown_payload);
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let file_index = FileIndex {
+ reader: Box::new(TrackingFileRead {
+ data: data.freeze(),
+ ranges: Arc::clone(&ranges),
+ }),
+ header: HashMap::from([(
+ "a".to_string(),
+ HashMap::from([
+ (
+ BITMAP_INDEX.to_string(),
+ IndexInfo {
+ start_pos: 0,
+ length: supported_end as i32,
+ },
+ ),
+ (
+ "unknown".to_string(),
+ IndexInfo {
+ start_pos: supported_end as i32,
+ length: unknown_payload.len() as i32,
+ },
+ ),
+ ]),
+ )]),
};
- assert!(matches!(error, Error::Unsupported { .. }));
+ let fields = [DataField::new(0, "a".to_string(), data_type)];
+
+ let readers = file_index
+ .create_index_readers(&fields, &HashSet::from(["a".to_string()]))
+ .await?;
+
+ assert_eq!(readers["a"].len(), 1);
+ assert_eq!(*ranges.lock().unwrap(), vec![0..supported_end]);
Ok(())
}
diff --git a/crates/paimon/src/file_index/file_index_reader.rs
b/crates/paimon/src/file_index/file_index_reader.rs
index be986099..f2e72226 100644
--- a/crates/paimon/src/file_index/file_index_reader.rs
+++ b/crates/paimon/src/file_index/file_index_reader.rs
@@ -19,7 +19,7 @@ use crate::file_index::file_index_result::FileIndexResult;
use crate::spec::{DataType, Datum, PredicateOperator};
/// Evaluates leaf predicates against one concrete file index.
-pub(crate) trait FileIndexReader {
+pub(crate) trait FileIndexReader: Send + Sync {
/// Evaluates the fields carried by [`crate::spec::Predicate::Leaf`].
///
/// Readers must return [`FileIndexResult::Remain`] for unsupported
operators.
diff --git a/crates/paimon/src/file_index/file_indexer_factory.rs
b/crates/paimon/src/file_index/file_indexer_factory.rs
index c6fb4b4b..7750e2ae 100644
--- a/crates/paimon/src/file_index/file_indexer_factory.rs
+++ b/crates/paimon/src/file_index/file_indexer_factory.rs
@@ -51,6 +51,10 @@ impl BuiltinFileIndexer {
pub(crate) struct FileIndexerFactory;
impl FileIndexerFactory {
+ pub(crate) fn is_supported(identifier: &str) -> bool {
+ matches!(identifier, BITMAP_INDEX | BLOOM_FILTER_INDEX)
+ }
+
pub(crate) fn create_writer(
identifier: &str,
data_type: DataType,
diff --git a/crates/paimon/src/file_index/mod.rs
b/crates/paimon/src/file_index/mod.rs
index c070ebdc..ba62ab8b 100644
--- a/crates/paimon/src/file_index/mod.rs
+++ b/crates/paimon/src/file_index/mod.rs
@@ -16,11 +16,12 @@
// under the License.
// Concrete readers/writers and predicate plumbing stay crate-private until
-// data-writer and scan integration land in later changes.
+// writer integration and the API surface are ready to stabilize.
#[allow(dead_code)]
pub(crate) mod bitmap;
#[allow(dead_code)]
pub(crate) mod bloom_filter;
+pub(crate) mod evaluator;
mod file_index_format;
#[allow(dead_code)]
pub(crate) mod file_index_predicate;
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 7afff6f6..7c0b74a6 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -23,6 +23,7 @@ const DELETION_VECTORS_ENABLED_OPTION: &str =
"deletion-vectors.enabled";
const DELETION_VECTORS_MERGE_ON_READ_OPTION: &str =
"deletion-vectors.merge-on-read";
pub(crate) const QUERY_AUTH_ENABLED_OPTION: &str = "query-auth.enabled";
const DATA_EVOLUTION_ENABLED_OPTION: &str = "data-evolution.enabled";
+const FILE_INDEX_READ_ENABLED_OPTION: &str = "file-index.read.enabled";
const GLOBAL_INDEX_ENABLED_OPTION: &str = "global-index.enabled";
const GLOBAL_INDEX_SEARCH_MODE_OPTION: &str = "global-index.search-mode";
const SCALAR_INDEX_SEARCH_MODE_OPTION: &str = "scalar-index.search-mode";
@@ -656,6 +657,14 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ /// Whether raw data-file reads use FileIndex pruning. Default is true.
+ pub fn file_index_read_enabled(&self) -> bool {
+ self.options
+ .get(FILE_INDEX_READ_ENABLED_OPTION)
+ .map(|value| value.eq_ignore_ascii_case("true"))
+ .unwrap_or(true)
+ }
+
/// The declared [`TableType`], defaulting to [`TableType::Table`].
/// Fails on a value this client does not know.
pub fn table_type(&self) -> crate::Result<TableType> {
@@ -1577,6 +1586,18 @@ mod tests {
}
}
+ #[test]
+ fn test_file_index_read_enabled() {
+ let options = HashMap::new();
+ assert!(CoreOptions::new(&options).file_index_read_enabled());
+
+ let options = HashMap::from([(
+ FILE_INDEX_READ_ENABLED_OPTION.to_string(),
+ "false".to_string(),
+ )]);
+ assert!(!CoreOptions::new(&options).file_index_read_enabled());
+ }
+
#[test]
fn test_parquet_row_group_read_budget_options() {
let options = HashMap::new();
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 8958b382..a6314a5e 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -20,6 +20,8 @@ use crate::arrow::format::create_format_reader_with_budget;
use crate::arrow::schema_evolution::{create_index_mapping, NULL_FIELD_INDEX};
use crate::arrow::ParquetReadBudget;
use crate::deletion_vector::{DeletionVector, DeletionVectorFactory};
+use crate::file_index::evaluator::evaluate_file_index;
+use crate::file_index::file_index_result::FileIndexResult;
use crate::io::{FileIO, FileRead};
use crate::spec::{
is_variant_extraction_row_type, DataField, DataFileMeta, DataType,
Predicate, ROW_ID_FIELD_NAME,
@@ -33,6 +35,7 @@ use arrow_cast::cast;
use async_stream::try_stream;
use futures::StreamExt;
+use roaring::RoaringBitmap;
use std::ops::Range;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
@@ -112,6 +115,7 @@ pub(crate) struct DataFileReader {
table_fields: Vec<DataField>,
read_type: Vec<DataField>,
predicates: Vec<Predicate>,
+ file_index_read_enabled: bool,
row_filter_factory: Option<Arc<dyn crate::arrow::RowFilterFactory>>,
blob_as_descriptor: bool,
batch_size: Option<usize>,
@@ -135,6 +139,7 @@ impl DataFileReader {
table_fields,
read_type,
predicates,
+ file_index_read_enabled: false,
row_filter_factory: None,
blob_as_descriptor: false,
batch_size: None,
@@ -148,6 +153,11 @@ impl DataFileReader {
self
}
+ pub(crate) fn with_file_index_read_enabled(mut self, enabled: bool) ->
Self {
+ self.file_index_read_enabled = enabled;
+ self
+ }
+
pub(crate) fn with_batch_size(mut self, batch_size: Option<usize>) -> Self
{
self.batch_size = batch_size;
self
@@ -257,12 +267,59 @@ impl DataFileReader {
timing.add_file_schema_open(start.elapsed());
}
- let mut stream = reader.read_single_file_stream(
+ let file_fields = data_fields
+ .as_deref()
+ .unwrap_or(reader.table_fields.as_slice());
+ let file_index_result = if reader.file_index_read_enabled {
+ evaluate_file_index(
+ &reader.file_io,
+ split.bucket_path(),
+ &file_meta,
+ &reader.table_fields,
+ file_fields,
+ &reader.predicates,
+ )
+ .await?
+ } else {
+ FileIndexResult::Remain
+ };
+
+ let split_ranges = split.row_ranges().map(|ranges| {
+ to_local_row_ranges(
+ ranges,
+ file_meta.first_row_id.unwrap_or(0),
+ file_meta.row_count,
+ )
+ });
+ let selected_ranges = match file_index_result {
+ FileIndexResult::Remain => split_ranges,
+ FileIndexResult::Skip => Some(Vec::new()),
+ FileIndexResult::Selection(selection) => {
+ match file_index_selection_to_local_ranges(
+ &selection,
+ file_meta.row_count,
+ )? {
+ Some(index_ranges) => Some(match split_ranges {
+ Some(split_ranges) => {
+ intersect_sorted_ranges(&index_ranges,
&split_ranges)
+ }
+ None => index_ranges,
+ }),
+ None => split_ranges,
+ }
+ }
+ };
+ let row_selection = merge_row_selection(
+ file_meta.row_count,
+ dv.as_deref(),
+ selected_ranges.as_deref(),
+ );
+
+ let mut stream =
reader.read_single_file_stream_with_selection(
&split,
file_meta,
data_fields,
- dv,
- split.row_ranges().map(|ranges| ranges.to_vec()),
+ row_selection,
)?;
while let Some(batch) = stream.next().await {
yield batch?;
@@ -339,6 +396,25 @@ impl DataFileReader {
data_fields: Option<Vec<DataField>>,
dv: Option<Arc<DeletionVector>>,
row_ranges: Option<Vec<RowRange>>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ 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());
+ self.read_single_file_stream_with_selection(split, file_meta,
data_fields, row_selection)
+ }
+
+ fn read_single_file_stream_with_selection(
+ &self,
+ split: &DataSplit,
+ file_meta: DataFileMeta,
+ data_fields: Option<Vec<DataField>>,
+ row_selection: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
// Guard at the true risk site: `_ROW_ID` is materialized positionally
from
// each batch's row count (see `row_id_column_for_batch`), assuming the
@@ -351,6 +427,9 @@ impl DataFileReader {
// data-evolution readers; both strip/omit `_ROW_ID` from the read_type
// they pass, so this guard does not affect them.
Self::reject_row_id_with_predicates(&self.read_type,
&self.predicates)?;
+ if row_selection.as_ref().is_some_and(Vec::is_empty) {
+ return Ok(futures::stream::empty().boxed());
+ }
let read_type = self.read_type.clone();
let table_fields = self.table_fields.clone();
@@ -440,21 +519,11 @@ impl DataFileReader {
None => Box::new(file_reader),
};
let is_parquet =
path_to_read.to_ascii_lowercase().ends_with(".parquet");
- 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 selected_row_ids = selected_row_ids_for_read(
+ projects_row_id,
+ file_meta.first_row_id,
+ row_selection.as_deref(),
);
- let selected_row_ids = match (file_meta.first_row_id,
row_selection.as_ref()) {
- (Some(first_row_id), Some(ranges)) => {
- Some(expand_local_selected_row_ids(first_row_id, ranges))
- }
- _ => None,
- };
let mut row_id_cursor = file_meta.first_row_id.unwrap_or(0);
let mut row_id_offset = 0usize;
@@ -891,24 +960,26 @@ fn is_row_file(file_meta: &DataFileMeta) -> bool {
.is_some_and(|path| path.to_ascii_lowercase().ends_with(".row"))
}
-/// Convert absolute RowRanges to file-local 0-based ranges.
+/// Convert absolute RowRanges to normalized 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()
+ crate::table::merge_row_ranges(
+ 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(),
+ )
}
/// Coalesce sorted, de-duplicated 0-based physical positions into contiguous
@@ -935,6 +1006,39 @@ fn coalesce_positions_to_local_ranges(sorted_positions:
&[i64]) -> Vec<RowRange>
ranges
}
+const MAX_FILE_INDEX_ROW_RANGES: usize = 65_536;
+
+/// Convert a bitmap into contiguous ranges without visiting every selected
row.
+/// `None` means the bitmap is too fragmented to materialize safely and callers
+/// must preserve other restrictions and rely on the residual predicate.
+fn file_index_selection_to_local_ranges(
+ selection: &RoaringBitmap,
+ row_count: i64,
+) -> crate::Result<Option<Vec<RowRange>>> {
+ if let Some(position) = selection.max() {
+ if i64::from(position) >= row_count {
+ return Err(Error::FileIndexFormatInvalid {
+ message: format!(
+ "FileIndex selected row position {position} outside data
file row count {row_count}"
+ ),
+ });
+ }
+ }
+
+ let mut ranges = Vec::new();
+ let mut positions = selection.iter();
+ while let Some(range) = positions.next_range() {
+ if ranges.len() == MAX_FILE_INDEX_ROW_RANGES {
+ return Ok(None);
+ }
+ ranges.push(RowRange::new(
+ i64::from(*range.start()),
+ i64::from(*range.end()),
+ ));
+ }
+ Ok(Some(ranges))
+}
+
/// 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).
///
@@ -1053,6 +1157,22 @@ fn expand_local_selected_row_ids(first_row_id: i64,
local_ranges: &[RowRange]) -
ids
}
+fn selected_row_ids_for_read(
+ projects_row_id: bool,
+ first_row_id: Option<i64>,
+ row_selection: Option<&[RowRange]>,
+) -> Option<Vec<i64>> {
+ if !projects_row_id {
+ return None;
+ }
+ match (first_row_id, row_selection) {
+ (Some(first_row_id), Some(ranges)) => {
+ Some(expand_local_selected_row_ids(first_row_id, ranges))
+ }
+ _ => None,
+ }
+}
+
fn row_id_column_for_batch(
first_row_id: Option<i64>,
num_rows: usize,
@@ -1486,10 +1606,17 @@ mod row_tests {
mod tests {
use super::*;
use crate::arrow::build_target_arrow_schema;
+ use crate::common::Options;
+ use crate::file_index::file_index_result::FileIndexResult;
+ use crate::file_index::file_indexer_factory::{
+ FileIndexerFactory, BITMAP_INDEX, BLOOM_FILTER_INDEX,
+ };
+ use crate::file_index::write_column_indexes;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- ArrayType, DataFileMeta, DataType, Datum, IntType, Predicate,
PredicateBuilder, VarCharType,
+ ArrayType, BigIntType, DataFileMeta, DataType, Datum, IntType,
Predicate, PredicateBuilder,
+ PredicateOperator, Schema, SchemaChange, TableSchema, VarCharType,
};
use crate::table::source::{DataSplitBuilder, DeletionFile};
use arrow_array::{Int32Array, StringArray};
@@ -1544,6 +1671,56 @@ mod tests {
);
}
+ #[test]
+ fn
file_index_selection_coalesces_positions_and_rejects_out_of_range_values() {
+ let selection = [0_u32, 1, 3].into_iter().collect();
+ assert_eq!(
+ file_index_selection_to_local_ranges(&selection, 4).unwrap(),
+ Some(vec![RowRange::new(0, 1), RowRange::new(3, 3)])
+ );
+
+ let out_of_range = [4_u32].into_iter().collect();
+ assert!(matches!(
+ file_index_selection_to_local_ranges(&out_of_range, 4),
+ Err(Error::FileIndexFormatInvalid { .. })
+ ));
+ }
+
+ #[test]
+ fn
dense_file_index_selection_stays_compact_and_fragmented_selection_falls_back() {
+ let mut dense = RoaringBitmap::new();
+ dense.insert_range(0..=10_000_000);
+ assert_eq!(
+ file_index_selection_to_local_ranges(&dense, 10_000_001).unwrap(),
+ Some(vec![RowRange::new(0, 10_000_000)])
+ );
+
+ let fragmented = (0..=MAX_FILE_INDEX_ROW_RANGES as u32)
+ .map(|position| position * 2)
+ .collect();
+ assert_eq!(
+ file_index_selection_to_local_ranges(&fragmented,
200_000).unwrap(),
+ None
+ );
+ }
+
+ #[test]
+ fn selected_row_ids_are_built_only_when_projected() {
+ let huge_selection = [RowRange::new(0, i64::from(i32::MAX))];
+ assert_eq!(
+ selected_row_ids_for_read(false, Some(10), Some(&huge_selection)),
+ None
+ );
+ assert_eq!(
+ selected_row_ids_for_read(
+ true,
+ Some(10),
+ Some(&[RowRange::new(1, 2), RowRange::new(4, 4)]),
+ ),
+ Some(vec![11, 12, 14])
+ );
+ }
+
#[test]
fn test_accessors_expose_read_type_and_row_filtering_predicate() {
use crate::spec::{DataField, DataType, IntType};
@@ -1696,6 +1873,34 @@ mod tests {
Bytes::from(writer.output().data.to_vec())
}
+ async fn file_index_bytes(
+ path: &str,
+ column: &str,
+ identifier: &str,
+ data_type: DataType,
+ options: &Options,
+ values: &[Datum],
+ ) -> Bytes {
+ let mut writer = FileIndexerFactory::create_writer(identifier,
data_type, options).unwrap();
+ for value in values {
+ writer.write(Some(value)).unwrap();
+ }
+ let indexes = std::collections::HashMap::from([(
+ column.to_string(),
+ std::collections::HashMap::from([(
+ identifier.to_string(),
+ Some(writer.serialized_bytes().unwrap()),
+ )]),
+ )]);
+ write_column_indexes(path, indexes)
+ .await
+ .unwrap()
+ .to_input_file()
+ .read()
+ .await
+ .unwrap()
+ }
+
#[tokio::test]
async fn test_mosaic_physical_missing_column_is_null_filled() {
let physical_fields = vec![
@@ -1895,6 +2100,406 @@ mod tests {
.collect()
}
+ #[tokio::test]
+ async fn
test_file_index_bitmap_skip_avoids_opening_data_file_and_disabled_falls_back() {
+ let fields = pk_fields();
+ let index = file_index_bytes(
+ "memory:/file_index_skip_source",
+ "id",
+ BITMAP_INDEX,
+ fields[0].data_type().clone(),
+ &Options::new(),
+ &[Datum::Int(1), Datum::Int(2)],
+ )
+ .await;
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/file_index_skip";
+ let bucket_path = format!("{table_path}/bucket-0");
+ let mut file = data_file("missing.mosaic", 1, 2, 0);
+ file.embedded_index = Some(index.to_vec());
+ 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![file])
+ .build()
+ .unwrap();
+ let predicate = PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(99))
+ .unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+
+ let enabled = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ 0,
+ fields.clone(),
+ fields.clone(),
+ vec![predicate.clone()],
+ )
+ .with_file_index_read_enabled(true)
+ .read(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert!(enabled.is_empty());
+
+ let disabled = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 0,
+ fields.clone(),
+ fields,
+ vec![predicate],
+ )
+ .with_file_index_read_enabled(false)
+ .read(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await;
+ assert!(
+ disabled.is_err(),
+ "disabled reads must preserve the data path"
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_file_index_bitmap_selection_intersects_unordered_split_ranges_and_deletion_vector(
+ ) {
+ let fields = pk_fields();
+ let data = write_mosaic(&pk_batch(
+ vec![1, 2, 3, 4, 5, 6],
+ vec!["a", "b", "c", "d", "e", "f"],
+ ));
+ let index = file_index_bytes(
+ "memory:/file_index_selection_source",
+ "id",
+ BITMAP_INDEX,
+ fields[0].data_type().clone(),
+ &Options::new(),
+ &[
+ Datum::Int(1),
+ Datum::Int(2),
+ Datum::Int(3),
+ Datum::Int(4),
+ Datum::Int(5),
+ Datum::Int(6),
+ ],
+ )
+ .await;
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/file_index_selection";
+ 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();
+ let sidecar_name = format!("{file_name}.index");
+ file_io
+ .new_output(&format!("{bucket_path}/{sidecar_name}"))
+ .unwrap()
+ .write(index)
+ .await
+ .unwrap();
+ let dv = write_deletion_file(&file_io,
&format!("{table_path}/index/dv-0"), &[3]).await;
+ let mut file = data_file(file_name, data.len() as i64, 6, 0);
+ file.extra_files = vec![sidecar_name];
+ 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![file])
+ .with_data_deletion_files(vec![Some(dv)])
+ .with_row_ranges(vec![
+ RowRange::new(4, 5),
+ RowRange::new(1, 3),
+ RowRange::new(2, 4),
+ ])
+ .build()
+ .unwrap();
+ let predicate = PredicateBuilder::new(&fields)
+ .is_in(
+ "id",
+ vec![Datum::Int(2), Datum::Int(3), Datum::Int(4),
Datum::Int(5)],
+ )
+ .unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+
+ for enabled in [true, false] {
+ let batches = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ 0,
+ fields.clone(),
+ fields.clone(),
+ vec![predicate.clone()],
+ )
+ .with_file_index_read_enabled(enabled)
+ .read(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(collect_ids(&batches), vec![2, 3, 5],
"enabled={enabled}");
+ }
+ }
+
+ #[tokio::test]
+ async fn
test_file_index_bloom_false_positive_is_removed_by_residual_filter() {
+ let fields = pk_fields();
+ let data_type = fields[0].data_type().clone();
+ let mut bloom_options = Options::new();
+ bloom_options.set("items", "1");
+ bloom_options.set("fpp", "0.99");
+ let mut writer = FileIndexerFactory::create_writer(
+ BLOOM_FILTER_INDEX,
+ data_type.clone(),
+ &bloom_options,
+ )
+ .unwrap();
+ writer.write(Some(&Datum::Int(1))).unwrap();
+ let payload = writer.serialized_bytes().unwrap();
+ let bloom_reader = FileIndexerFactory::create_reader(
+ BLOOM_FILTER_INDEX,
+ data_type.clone(),
+ payload.clone(),
+ )
+ .unwrap();
+ let false_positive = (2..10_000)
+ .find(|candidate| {
+ bloom_reader.evaluate(
+ "id",
+ 0,
+ &data_type,
+ PredicateOperator::Eq,
+ &[Datum::Int(*candidate)],
+ ) == FileIndexResult::Remain
+ })
+ .expect("high-FPP Bloom filter should have a false positive");
+ let indexes = std::collections::HashMap::from([(
+ "id".to_string(),
+ std::collections::HashMap::from([(BLOOM_FILTER_INDEX.to_string(),
Some(payload))]),
+ )]);
+ let index = write_column_indexes("memory:/file_index_bloom_source",
indexes)
+ .await
+ .unwrap()
+ .to_input_file()
+ .read()
+ .await
+ .unwrap();
+
+ let data = write_mosaic(&pk_batch(vec![1], vec!["a"]));
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/file_index_bloom";
+ 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();
+ let mut file = data_file(file_name, data.len() as i64, 1, 0);
+ file.embedded_index = Some(index.to_vec());
+ 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![file])
+ .build()
+ .unwrap();
+ let predicate = PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(false_positive))
+ .unwrap();
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let batches = DataFileReader::new(
+ file_io,
+ schema_manager,
+ 0,
+ fields.clone(),
+ fields,
+ vec![predicate],
+ )
+ .with_file_index_read_enabled(true)
+ .read(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert!(collect_ids(&batches).is_empty());
+ }
+
+ #[tokio::test]
+ async fn
test_file_index_uses_schema_evolved_file_fields_and_remapped_predicate() {
+ let old_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("old_id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap(),
+ );
+ let current_schema = old_schema
+ .apply_changes(vec![
+ SchemaChange::rename_column("old_id".to_string(),
"new_id".to_string()),
+ SchemaChange::update_column_type(
+ "new_id".to_string(),
+ DataType::BigInt(BigIntType::new()),
+ ),
+ ])
+ .unwrap();
+ let index = file_index_bytes(
+ "memory:/file_index_schema_evolution_source",
+ "old_id",
+ BITMAP_INDEX,
+ old_schema.fields()[0].data_type().clone(),
+ &Options::new(),
+ &[Datum::Int(1), Datum::Int(2)],
+ )
+ .await;
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/file_index_schema_evolution";
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let schema_path = schema_manager.schema_path(old_schema.id());
+ let schema_dir = schema_path.rsplit_once('/').unwrap().0;
+ file_io.mkdirs(schema_dir).await.unwrap();
+ file_io
+ .new_output(&schema_path)
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(&old_schema).unwrap()))
+ .await
+ .unwrap();
+ let mut file = data_file("missing.mosaic", 1, 2, old_schema.id());
+ file.embedded_index = Some(index.to_vec());
+ let split = DataSplitBuilder::new()
+ .with_snapshot(1)
+ .with_partition(crate::spec::BinaryRow::new(0))
+ .with_bucket(0)
+ .with_bucket_path(format!("{table_path}/bucket-0"))
+ .with_total_buckets(1)
+ .with_data_files(vec![file])
+ .build()
+ .unwrap();
+ let predicate = PredicateBuilder::new(current_schema.fields())
+ .equal("new_id", Datum::Long(99))
+ .unwrap();
+
+ let batches = DataFileReader::new(
+ file_io,
+ schema_manager,
+ current_schema.id(),
+ current_schema.fields().to_vec(),
+ current_schema.fields().to_vec(),
+ vec![predicate],
+ )
+ .with_file_index_read_enabled(true)
+ .read(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert!(batches.is_empty());
+ }
+
+ #[tokio::test]
+ async fn test_file_index_nested_not_with_added_column_falls_back() {
+ let old_schema = TableSchema::new(
+ 0,
+ &Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .build()
+ .unwrap(),
+ );
+ let current_schema = old_schema
+ .apply_changes(vec![SchemaChange::add_column(
+ "added".to_string(),
+ DataType::Int(IntType::new()),
+ )])
+ .unwrap();
+ let batch = RecordBatch::try_new(
+ build_target_arrow_schema(old_schema.fields()).unwrap(),
+ vec![Arc::new(Int32Array::from(vec![1, 2]))],
+ )
+ .unwrap();
+ let data = write_mosaic(&batch);
+ let index = file_index_bytes(
+ "memory:/file_index_nested_not_source",
+ "id",
+ BITMAP_INDEX,
+ old_schema.fields()[0].data_type().clone(),
+ &Options::new(),
+ &[Datum::Int(1), Datum::Int(2)],
+ )
+ .await;
+
+ let file_io = FileIOBuilder::new("memory").build().unwrap();
+ let table_path = "memory:/file_index_nested_not";
+ 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();
+ let schema_manager = SchemaManager::new(file_io.clone(),
table_path.to_string());
+ let schema_path = schema_manager.schema_path(old_schema.id());
+ let schema_dir = schema_path.rsplit_once('/').unwrap().0;
+ file_io.mkdirs(schema_dir).await.unwrap();
+ file_io
+ .new_output(&schema_path)
+ .unwrap()
+ .write(Bytes::from(serde_json::to_vec(&old_schema).unwrap()))
+ .await
+ .unwrap();
+
+ let mut file = data_file(file_name, data.len() as i64, 2,
old_schema.id());
+ file.embedded_index = Some(index.to_vec());
+ 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![file])
+ .build()
+ .unwrap();
+ let builder = PredicateBuilder::new(current_schema.fields());
+ let predicate = Predicate::negate(Predicate::and(vec![
+ Predicate::negate(builder.equal("id", Datum::Int(1)).unwrap()),
+ builder.equal("added", Datum::Int(2)).unwrap(),
+ ]));
+
+ for enabled in [true, false] {
+ let batches = DataFileReader::new(
+ file_io.clone(),
+ schema_manager.clone(),
+ current_schema.id(),
+ current_schema.fields().to_vec(),
+ vec![current_schema.fields()[0].clone()],
+ vec![predicate.clone()],
+ )
+ .with_file_index_read_enabled(enabled)
+ .read(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(collect_ids(&batches), vec![1, 2], "enabled={enabled}");
+ }
+ }
+
/// Deletion vectors are applied format-agnostically by `DataFileReader`;
verify a
/// Mosaic file honors deleted rows end to end.
#[tokio::test]
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 99c01f7d..acaebeeb 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -382,6 +382,7 @@ impl<'a> PaimonTableRead<'a> {
has_value_kind: bool,
) -> crate::Result<ArrowRecordBatchStream> {
plan.validate()?;
+ let core_options = self.table.schema().core_options();
let data_splits = plan.data_splits();
let user_read_type = self.read_type.clone();
let include_sequence = audit_sequence_number_enabled(self.table);
@@ -414,7 +415,8 @@ impl<'a> PaimonTableRead<'a> {
read_type,
self.data_predicates.clone(),
)
-
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?))
+ .with_file_index_read_enabled(core_options.file_index_read_enabled())
+ .with_batch_size(Some(core_options.read_batch_size()?))
.with_parquet_read_budget(Some(self.parquet_read_budget()?));
let raw_stream = reader.read(&data_splits)?;
@@ -883,6 +885,7 @@ impl<'a> PaimonTableRead<'a> {
}
fn new_data_file_reader(&self) -> crate::Result<DataFileReader> {
+ let core_options = self.table.schema().core_options();
let mut reader = DataFileReader::new(
self.table.file_io.clone(),
self.table.schema_manager().clone(),
@@ -891,7 +894,8 @@ impl<'a> PaimonTableRead<'a> {
self.read_type().to_vec(),
self.data_predicates.clone(),
)
-
.with_batch_size(Some(self.table.schema().core_options().read_batch_size()?))
+ .with_file_index_read_enabled(core_options.file_index_read_enabled())
+ .with_batch_size(Some(core_options.read_batch_size()?))
.with_parquet_read_budget(Some(self.parquet_read_budget()?))
.with_read_timing(self.data_file_read_timing.clone());
// The engine decoder filter is safe only on the plain append/raw path.
@@ -1481,11 +1485,17 @@ fn pk_split_needs_merge(split: &DataSplit, dv_enabled:
bool) -> bool {
mod tests {
use super::*;
use crate::catalog::Identifier;
+ use crate::common::Options;
+ use crate::file_index::file_indexer_factory::{FileIndexerFactory,
BITMAP_INDEX};
+ use crate::file_index::write_column_indexes;
use crate::io::FileIOBuilder;
use crate::spec::stats::BinaryTableStats;
- use crate::spec::{BinaryRow, DataFileMeta, DataType, IntType, Schema,
TableSchema};
+ use crate::spec::{
+ BinaryRow, DataFileMeta, DataType, Datum, IntType, PredicateBuilder,
Schema, TableSchema,
+ };
use crate::table::query_auth_table;
use crate::table::source::DataSplitBuilder;
+ use futures::TryStreamExt;
fn file(name: &str, level: i32, delete_row_count: Option<i64>) ->
DataFileMeta {
DataFileMeta {
@@ -1542,6 +1552,101 @@ mod tests {
)
}
+ async fn embedded_bitmap_index() -> Vec<u8> {
+ let mut writer = FileIndexerFactory::create_writer(
+ BITMAP_INDEX,
+ DataType::Int(IntType::new()),
+ &Options::new(),
+ )
+ .unwrap();
+ writer.write(Some(&Datum::Int(1))).unwrap();
+ let indexes = std::collections::HashMap::from([(
+ "id".to_string(),
+ std::collections::HashMap::from([(
+ BITMAP_INDEX.to_string(),
+ Some(writer.serialized_bytes().unwrap()),
+ )]),
+ )]);
+ write_column_indexes("memory:/table_read_file_index_source", indexes)
+ .await
+ .unwrap()
+ .to_input_file()
+ .read()
+ .await
+ .unwrap()
+ .to_vec()
+ }
+
+ fn file_index_table(path: &str, enabled: Option<bool>) -> Table {
+ let mut builder = Schema::builder().column("id",
DataType::Int(IntType::new()));
+ if let Some(enabled) = enabled {
+ builder = builder.option("file-index.read.enabled",
enabled.to_string());
+ }
+ Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "file_index_t"),
+ path.to_string(),
+ TableSchema::new(0, &builder.build().unwrap()),
+ None,
+ )
+ }
+
+ #[tokio::test]
+ async fn test_raw_table_read_paths_honor_file_index_read_option() {
+ let mut indexed_file = file("missing.mosaic", 5, Some(0));
+ indexed_file.row_count = 1;
+ indexed_file.embedded_index = Some(embedded_bitmap_index().await);
+ let split = split(vec![indexed_file], true);
+ let table = file_index_table("memory:/table_read_file_index", None);
+ let fields = table.schema().fields().to_vec();
+ let predicate = PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(99))
+ .unwrap();
+ let read = TableRead::new(&table, fields, vec![predicate]);
+
+ let normal = read
+ .to_arrow(std::slice::from_ref(&split))
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert!(normal.is_empty());
+
+ let plan = IncrementalPlan::new(
+ IncrementalScanMode::Delta,
+ vec![IncrementalSplit::Data(split.clone())],
+ );
+ let incremental = read
+ .to_incremental_arrow(&plan)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert!(incremental.is_empty());
+ let audit = read
+ .to_audit_log_arrow(&plan)
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert!(audit.is_empty());
+
+ let disabled_table =
+ file_index_table("memory:/table_read_file_index_disabled",
Some(false));
+ let disabled_fields = disabled_table.schema().fields().to_vec();
+ let disabled_predicate = PredicateBuilder::new(&disabled_fields)
+ .equal("id", Datum::Int(99))
+ .unwrap();
+ let disabled_read =
+ TableRead::new(&disabled_table, disabled_fields,
vec![disabled_predicate]);
+ let disabled = disabled_read
+ .to_arrow(&[split])
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await;
+ assert!(disabled.is_err());
+ }
+
#[test]
fn test_pk_split_needs_merge_routing() {
// Raw convertible with known delete counts: raw read.