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 0aba451b feat(file_index): add indexer contracts and outer-format
composition (#764)
0aba451b is described below
commit 0aba451bd978df08d5ba9245cb353126fc60ec2b
Author: QuakeWang <[email protected]>
AuthorDate: Tue Sep 1 11:54:14 2026 +0800
feat(file_index): add indexer contracts and outer-format composition (#764)
---
crates/paimon/src/file_index/bitmap/mod.rs | 1 +
crates/paimon/src/file_index/bitmap/writer.rs | 11 +-
crates/paimon/src/file_index/bloom_filter/mod.rs | 44 ++-
crates/paimon/src/file_index/file_index_format.rs | 373 ++++++++++++++++++++-
crates/paimon/src/file_index/file_index_reader.rs | 75 +++++
.../file_index/{mod.rs => file_index_writer.rs} | 29 +-
.../paimon/src/file_index/file_indexer_factory.rs | 169 ++++++++++
crates/paimon/src/file_index/mod.rs | 6 +-
8 files changed, 675 insertions(+), 33 deletions(-)
diff --git a/crates/paimon/src/file_index/bitmap/mod.rs
b/crates/paimon/src/file_index/bitmap/mod.rs
index 105cd739..b45f67e9 100644
--- a/crates/paimon/src/file_index/bitmap/mod.rs
+++ b/crates/paimon/src/file_index/bitmap/mod.rs
@@ -953,6 +953,7 @@ impl FileIndexReader for BitmapFileIndexReader {
mod tests {
use super::*;
use crate::common::Options;
+ use crate::file_index::file_index_writer::FileIndexWriter;
use crate::spec::{
BigIntType, BinaryType, BooleanType, CharType, DateType, DoubleType,
FloatType, IntType,
LocalZonedTimestampType, SmallIntType, TimeType, TimestampType,
TinyIntType, VarCharType,
diff --git a/crates/paimon/src/file_index/bitmap/writer.rs
b/crates/paimon/src/file_index/bitmap/writer.rs
index cc63573e..c2c68f78 100644
--- a/crates/paimon/src/file_index/bitmap/writer.rs
+++ b/crates/paimon/src/file_index/bitmap/writer.rs
@@ -23,6 +23,7 @@ use roaring::RoaringBitmap;
use crate::common::options::parse_memory_size;
use crate::common::Options;
+use crate::file_index::file_index_writer::FileIndexWriter;
use crate::spec::{DataType, Datum};
use crate::{Error, Result};
@@ -56,8 +57,10 @@ impl BitmapFileIndexWriter {
bitmaps: HashMap::new(),
})
}
+}
- pub(crate) fn write(&mut self, datum: Option<&Datum>) -> Result<()> {
+impl FileIndexWriter for BitmapFileIndexWriter {
+ fn write(&mut self, datum: Option<&Datum>) -> Result<()> {
if self.row_count == i32::MAX as u32 {
return Err(Error::DataInvalid {
message: "Bitmap row count exceeds i32::MAX".to_string(),
@@ -81,7 +84,7 @@ impl BitmapFileIndexWriter {
Ok(())
}
- pub(crate) fn serialized_bytes(&mut self) -> Result<Bytes> {
+ fn serialized_bytes(&mut self) -> Result<Bytes> {
let null_bytes = serialize_bitmap(&mut self.null_bitmap)?;
let mut body = Vec::new();
let null_entry = if self.null_bitmap.is_empty() {
@@ -162,6 +165,10 @@ impl BitmapFileIndexWriter {
output.extend_from_slice(&body);
Ok(output.freeze())
}
+
+ fn empty(&self) -> bool {
+ self.row_count == 0
+ }
}
struct SerializedEntry<'a> {
diff --git a/crates/paimon/src/file_index/bloom_filter/mod.rs
b/crates/paimon/src/file_index/bloom_filter/mod.rs
index 9d5708ed..d70a5e85 100644
--- a/crates/paimon/src/file_index/bloom_filter/mod.rs
+++ b/crates/paimon/src/file_index/bloom_filter/mod.rs
@@ -23,6 +23,7 @@ use bytes::{Bytes, BytesMut};
use crate::common::Options;
use crate::file_index::file_index_reader::FileIndexReader;
use crate::file_index::file_index_result::FileIndexResult;
+use crate::file_index::file_index_writer::FileIndexWriter;
use crate::spec::{DataType, Datum, PredicateOperator};
use crate::{Error, Result};
@@ -37,6 +38,7 @@ const FPP: &str = "fpp";
pub(crate) struct BloomFilterWriter {
hash_function: FastHash,
filter: BloomFilter64,
+ empty: bool,
}
impl BloomFilterWriter {
@@ -48,21 +50,29 @@ impl BloomFilterWriter {
Ok(Self {
hash_function,
filter,
+ empty: true,
})
}
+}
- pub(crate) fn write(&mut self, datum: Option<&Datum>) -> Result<()> {
+impl FileIndexWriter for BloomFilterWriter {
+ fn write(&mut self, datum: Option<&Datum>) -> Result<()> {
if let Some(datum) = datum {
self.filter.add_hash(self.hash_function.hash(datum)?);
}
+ self.empty = false;
Ok(())
}
- pub(crate) fn serialized_bytes(&self) -> Bytes {
+ fn serialized_bytes(&mut self) -> Result<Bytes> {
let mut serialized = BytesMut::with_capacity(4 +
self.filter.bytes().len());
serialized.extend_from_slice(&self.filter.num_hash_functions().to_be_bytes());
serialized.extend_from_slice(self.filter.bytes());
- serialized.freeze()
+ Ok(serialized.freeze())
+ }
+
+ fn empty(&self) -> bool {
+ self.empty
}
}
@@ -198,7 +208,7 @@ mod tests {
}
writer.write(None).unwrap();
- let serialized = writer.serialized_bytes();
+ let serialized = writer.serialized_bytes().unwrap();
assert_eq!(
serialized.as_ref(),
&hex::decode("00000003818281005001").unwrap()
@@ -253,7 +263,7 @@ mod tests {
.unwrap();
writer.write(Some(&Datum::Long(42))).unwrap();
- let serialized = writer.serialized_bytes();
+ let serialized = writer.serialized_bytes().unwrap();
assert_eq!(&serialized[..4], &133_i32.to_be_bytes());
assert_eq!(serialized.len(), 28);
@@ -279,7 +289,8 @@ mod tests {
.write(Some(&Datum::Float(f32::from_bits(0x7fc0_0001))))
.unwrap();
let reader =
- BloomFilterReader::try_new(float_type.clone(),
writer.serialized_bytes()).unwrap();
+ BloomFilterReader::try_new(float_type.clone(),
writer.serialized_bytes().unwrap())
+ .unwrap();
assert_eq!(
evaluate(
@@ -323,7 +334,8 @@ mod tests {
BloomFilterWriter::try_new(double_type.clone(), &options("10",
"0.1")).unwrap();
writer.write(Some(&Datum::Double(-0.0))).unwrap();
let reader =
- BloomFilterReader::try_new(double_type.clone(),
writer.serialized_bytes()).unwrap();
+ BloomFilterReader::try_new(double_type.clone(),
writer.serialized_bytes().unwrap())
+ .unwrap();
assert_eq!(
evaluate(
&reader,
@@ -342,7 +354,8 @@ mod tests {
let bigint = DataType::BigInt(BigIntType::new());
let mut writer = BloomFilterWriter::try_new(bigint.clone(),
&options).unwrap();
writer.write(Some(&Datum::Long(42))).unwrap();
- let reader = BloomFilterReader::try_new(bigint,
writer.serialized_bytes()).unwrap();
+ let reader =
+ BloomFilterReader::try_new(bigint,
writer.serialized_bytes().unwrap()).unwrap();
assert_eq!(
evaluate(
&reader,
@@ -358,7 +371,8 @@ mod tests {
writer
.write(Some(&Datum::String("abc".to_string())))
.unwrap();
- let reader = BloomFilterReader::try_new(char_type,
writer.serialized_bytes()).unwrap();
+ let reader =
+ BloomFilterReader::try_new(char_type,
writer.serialized_bytes().unwrap()).unwrap();
assert_eq!(
evaluate(
&reader,
@@ -377,7 +391,8 @@ mod tests {
nanos: 456_000,
}))
.unwrap();
- let reader = BloomFilterReader::try_new(timestamp4,
writer.serialized_bytes()).unwrap();
+ let reader =
+ BloomFilterReader::try_new(timestamp4,
writer.serialized_bytes().unwrap()).unwrap();
let missing = [Datum::Timestamp {
millis: 1_700_000_000_124,
nanos: 456_000,
@@ -479,7 +494,8 @@ mod tests {
BloomFilterWriter::try_new(data_type.clone(), &options("10",
"0.1")).unwrap();
writer.write(Some(&datum)).unwrap();
let reader =
- BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes()).unwrap();
+ BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes().unwrap())
+ .unwrap();
assert_eq!(
evaluate(&reader, &data_type, PredicateOperator::Eq, &[datum]),
@@ -509,7 +525,8 @@ mod tests {
writer.write(Some(&Datum::Long(*value))).unwrap();
}
let reader =
- BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes()).unwrap();
+ BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes().unwrap())
+ .unwrap();
for value in inserted {
assert_eq!(
@@ -547,7 +564,8 @@ mod tests {
BloomFilterWriter::try_new(data_type.clone(), &options("10",
"0.1")).unwrap();
writer.write(Some(&Datum::Long(42))).unwrap();
let reader =
- BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes()).unwrap();
+ BloomFilterReader::try_new(data_type.clone(),
writer.serialized_bytes().unwrap())
+ .unwrap();
for operator in [
PredicateOperator::IsNull,
diff --git a/crates/paimon/src/file_index/file_index_format.rs
b/crates/paimon/src/file_index/file_index_format.rs
index 6bd0f4ca..f5fcb22a 100644
--- a/crates/paimon/src/file_index/file_index_format.rs
+++ b/crates/paimon/src/file_index/file_index_format.rs
@@ -15,12 +15,17 @@
// specific language governing permissions and limitations
// under the License.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use bytes::{BufMut, Bytes, BytesMut};
use crate::{
+ file_index::{
+ file_index_reader::{EmptyFileIndexReader, FileIndexReader},
+ file_indexer_factory::FileIndexerFactory,
+ },
io::{FileIO, FileRead, FileStatus, InputFile, OutputFile},
+ spec::{DataField, DataType},
Error,
};
@@ -329,12 +334,78 @@ fn calculate_head_length(
Ok(total_length)
}
+fn resolve_index_data_type(
+ fields_by_name: &HashMap<&str, &DataType>,
+ column_name: &str,
+) -> crate::Result<DataType> {
+ let nested_start = column_name.find('[').filter(|_|
column_name.ends_with(']'));
+ let field_name = nested_start
+ .map(|index| &column_name[..index])
+ .unwrap_or(column_name);
+ let data_type = fields_by_name.get(field_name).copied().ok_or_else(|| {
+ format_invalid(format!(
+ "Column '{field_name}' for file index '{column_name}' was not
found in schema"
+ ))
+ })?;
+
+ match (nested_start, data_type) {
+ (Some(_), DataType::Map(map_type)) =>
Ok(map_type.value_type().clone()),
+ (Some(_), data_type) => Err(format_invalid(format!(
+ "Nested file index '{column_name}' requires Map column
'{field_name}', but found {data_type:?}"
+ ))),
+ (None, data_type) => Ok(data_type.clone()),
+ }
+}
+
pub struct FileIndex {
reader: Box<dyn FileRead>,
header: HashMap<String, HashMap<String, IndexInfo>>,
}
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],
+ required_columns: &HashSet<String>,
+ ) -> crate::Result<HashMap<String, Vec<Box<dyn FileIndexReader>>>> {
+ let fields_by_name = fields
+ .iter()
+ .map(|field| (field.name(), field.data_type()))
+ .collect::<HashMap<&str, &DataType>>();
+ let mut readers = HashMap::with_capacity(required_columns.len());
+
+ for column_name in required_columns {
+ let Some(index_info) = self.header.get(column_name) else {
+ continue;
+ };
+ let mut column_readers = Vec::with_capacity(index_info.len());
+ for (identifier, info) in index_info {
+ if info.start_pos == EMPTY_INDEX_FLAG {
+ column_readers.push(Box::new(EmptyFileIndexReader) as
Box<dyn FileIndexReader>);
+ continue;
+ }
+
+ let data_type = resolve_index_data_type(&fields_by_name,
column_name)?;
+ let serialized = self
+ .get_bytes_with_start_and_length(info)
+ .await?
+ .ok_or_else(|| {
+ format_invalid(format!(
+ "Non-empty file index '{identifier}' for column
'{column_name}' had no payload"
+ ))
+ })?;
+ column_readers.push(FileIndexerFactory::create_reader(
+ identifier, data_type, serialized,
+ )?);
+ }
+ readers.insert(column_name.clone(), column_readers);
+ }
+
+ Ok(readers)
+ }
+
pub async fn get_column_index(
&self,
column_name: &str,
@@ -557,8 +628,21 @@ impl FileIndexFormatReader {
mod file_index_format_tests {
use super::*;
- use bytes::Bytes;
- use std::collections::HashMap;
+ use bytes::{Bytes, BytesMut};
+ use std::collections::{HashMap, HashSet};
+ use std::ops::Range;
+ use std::sync::{Arc, Mutex};
+
+ use crate::common::Options;
+ use crate::file_index::file_index_predicate::FileIndexPredicate;
+ use crate::file_index::file_index_result::FileIndexResult;
+ use crate::file_index::file_indexer_factory::{
+ FileIndexerFactory, BITMAP_INDEX, BLOOM_FILTER_INDEX,
+ };
+ use crate::spec::{
+ BigIntType, Datum, IntType, MapType, Predicate, PredicateBuilder,
PredicateOperator,
+ VarCharType,
+ };
const JAVA_V1_SIMPLE: &str = concat!(
"00054e4ed01a35ae000000010000002a0000000100016100000001000162",
@@ -581,6 +665,111 @@ mod file_index_format_tests {
Ok(output.to_input_file())
}
+ struct TrackingFileRead {
+ data: Bytes,
+ ranges: Arc<Mutex<Vec<Range<u64>>>>,
+ }
+
+ #[async_trait::async_trait]
+ impl FileRead for TrackingFileRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ self.ranges.lock().unwrap().push(range.clone());
+ Ok(self.data.slice(range.start as usize..range.end as usize))
+ }
+ }
+
+ #[test]
+ fn test_resolve_index_data_type_matches_java_nested_name_rules() {
+ let value_type = DataType::BigInt(BigIntType::new());
+ let map_type = DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::new(20).unwrap()),
+ value_type.clone(),
+ ));
+ let exact_unclosed_type = DataType::Int(IntType::new());
+ let plain_type = DataType::Int(IntType::new());
+ let fields_by_name = HashMap::from([
+ ("metrics", &map_type),
+ ("metrics[k", &exact_unclosed_type),
+ ("plain", &plain_type),
+ ]);
+
+ assert_eq!(
+ resolve_index_data_type(&fields_by_name, "metrics[k]").unwrap(),
+ value_type
+ );
+ assert_eq!(
+ resolve_index_data_type(&fields_by_name, "metrics[k").unwrap(),
+ exact_unclosed_type
+ );
+ assert!(matches!(
+ resolve_index_data_type(&fields_by_name, "metrics[k][nested]"),
+ Ok(DataType::BigInt(_))
+ ));
+ assert!(matches!(
+ resolve_index_data_type(&fields_by_name, "metrics[k][nested"),
+ Err(Error::FileIndexFormatInvalid { .. })
+ ));
+ assert!(matches!(
+ resolve_index_data_type(&fields_by_name, "plain[k]"),
+ Err(Error::FileIndexFormatInvalid { .. })
+ ));
+ }
+
+ #[tokio::test]
+ async fn test_composition_reads_only_required_column_payloads() ->
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 required_payload = writer.serialized_bytes()?;
+ let unrelated_payload = Bytes::from(vec![0; 1024]);
+ let required_end = required_payload.len() as u64;
+ let unrelated_end = required_end + unrelated_payload.len() as u64;
+ let mut data = BytesMut::with_capacity(unrelated_end as usize);
+ data.extend_from_slice(&required_payload);
+ data.extend_from_slice(&unrelated_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([
+ (
+ "required".to_string(),
+ HashMap::from([(
+ BITMAP_INDEX.to_string(),
+ IndexInfo {
+ start_pos: 0,
+ length: required_end as i32,
+ },
+ )]),
+ ),
+ (
+ "unrelated".to_string(),
+ HashMap::from([(
+ "unknown".to_string(),
+ IndexInfo {
+ start_pos: required_end as i32,
+ length: unrelated_payload.len() as i32,
+ },
+ )]),
+ ),
+ ]),
+ };
+ let fields = [DataField::new(0, "required".to_string(), data_type)];
+ let required_columns = HashSet::from(["required".to_string()]);
+
+ let readers = file_index
+ .create_index_readers(&fields, &required_columns)
+ .await?;
+
+ assert_eq!(readers.len(), 1);
+ assert_eq!(readers["required"].len(), 1);
+ assert_eq!(*ranges.lock().unwrap(), vec![0..required_end]);
+ Ok(())
+ }
+
#[tokio::test]
async fn test_writer_matches_java_v1_bytes() -> crate::Result<()> {
let indexes = HashMap::from([(
@@ -835,6 +1024,184 @@ mod file_index_format_tests {
Ok(())
}
+ #[tokio::test]
+ async fn
test_outer_format_builds_grouped_readers_and_evaluates_predicates() ->
crate::Result<()>
+ {
+ let fields = vec![
+ DataField::new(0, "a".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "b".to_string(),
DataType::BigInt(BigIntType::new())),
+ DataField::new(2, "empty".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let mut bloom_options = Options::new();
+ bloom_options.set("items", "10");
+ bloom_options.set("fpp", "0.1");
+
+ let mut bitmap = FileIndexerFactory::create_writer(
+ BITMAP_INDEX,
+ fields[0].data_type().clone(),
+ &Options::new(),
+ )?;
+ let mut bloom = FileIndexerFactory::create_writer(
+ BLOOM_FILTER_INDEX,
+ fields[0].data_type().clone(),
+ &bloom_options,
+ )?;
+ for value in [
+ Some(Datum::Int(1)),
+ Some(Datum::Int(2)),
+ None,
+ Some(Datum::Int(1)),
+ ] {
+ bitmap.write(value.as_ref())?;
+ bloom.write(value.as_ref())?;
+ }
+
+ let mut b_bloom = FileIndexerFactory::create_writer(
+ BLOOM_FILTER_INDEX,
+ fields[1].data_type().clone(),
+ &bloom_options,
+ )?;
+ b_bloom.write(Some(&Datum::Long(42)))?;
+
+ let indexes = HashMap::from([
+ (
+ "a".to_string(),
+ HashMap::from([
+ (BITMAP_INDEX.to_string(),
Some(bitmap.serialized_bytes()?)),
+ (
+ BLOOM_FILTER_INDEX.to_string(),
+ Some(bloom.serialized_bytes()?),
+ ),
+ ]),
+ ),
+ (
+ "b".to_string(),
+ HashMap::from([(
+ BLOOM_FILTER_INDEX.to_string(),
+ Some(b_bloom.serialized_bytes()?),
+ )]),
+ ),
+ (
+ "empty".to_string(),
+ HashMap::from([("unregistered-empty-index".to_string(),
None)]),
+ ),
+ ]);
+
+ let output = write_column_indexes("memory:/tmp/composed_file_indexes",
indexes).await?;
+ let file_index =
FileIndexFormatReader::get_file_index(output.to_input_file()).await?;
+ let required_columns =
+ HashSet::from(["a".to_string(), "b".to_string(),
"empty".to_string()]);
+ let readers = file_index
+ .create_index_readers(&fields, &required_columns)
+ .await?;
+ assert_eq!(readers.len(), 3);
+ assert_eq!(readers["a"].len(), 2);
+ assert_eq!(readers["b"].len(), 1);
+ assert_eq!(readers["empty"].len(), 1);
+
+ let predicate = FileIndexPredicate::new(readers);
+ let builder = PredicateBuilder::new(&fields);
+ assert_eq!(
+ predicate.evaluate(&builder.equal("a", Datum::Int(1))?),
+ FileIndexResult::Selection([0_u32, 3].into_iter().collect())
+ );
+ assert_eq!(
+ predicate.evaluate(&builder.equal("b", Datum::Long(43))?),
+ FileIndexResult::Skip
+ );
+ assert_eq!(
+ predicate.evaluate(&builder.equal("empty", Datum::Int(1))?),
+ FileIndexResult::Skip
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_outer_format_uses_map_value_type_for_nested_index() ->
crate::Result<()> {
+ let value_type = DataType::BigInt(BigIntType::new());
+ let fields = [DataField::new(
+ 0,
+ "metrics".to_string(),
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::new(20).unwrap()),
+ value_type.clone(),
+ )),
+ )];
+ let mut bloom_options = Options::new();
+ bloom_options.set("items", "10");
+ bloom_options.set("fpp", "0.1");
+ let mut bitmap =
+ FileIndexerFactory::create_writer(BITMAP_INDEX,
value_type.clone(), &Options::new())?;
+ let mut bloom = FileIndexerFactory::create_writer(
+ BLOOM_FILTER_INDEX,
+ value_type.clone(),
+ &bloom_options,
+ )?;
+ for value in [Datum::Long(7), Datum::Long(8)] {
+ bitmap.write(Some(&value))?;
+ bloom.write(Some(&value))?;
+ }
+ let indexes = HashMap::from([(
+ "metrics[k]".to_string(),
+ HashMap::from([
+ (BITMAP_INDEX.to_string(), Some(bitmap.serialized_bytes()?)),
+ (
+ BLOOM_FILTER_INDEX.to_string(),
+ Some(bloom.serialized_bytes()?),
+ ),
+ ]),
+ )]);
+
+ let output =
write_column_indexes("memory:/tmp/nested_map_file_indexes", indexes).await?;
+ let file_index =
FileIndexFormatReader::get_file_index(output.to_input_file()).await?;
+ let required_columns = HashSet::from(["metrics[k]".to_string()]);
+ let readers = file_index
+ .create_index_readers(&fields, &required_columns)
+ .await?;
+ assert_eq!(readers["metrics[k]"].len(), 2);
+
+ let predicate = FileIndexPredicate::new(readers);
+ assert_eq!(
+ predicate.evaluate(&Predicate::Leaf {
+ column: "metrics[k]".to_string(),
+ index: 0,
+ data_type: value_type,
+ op: PredicateOperator::Eq,
+ literals: vec![Datum::Long(7)],
+ }),
+ FileIndexResult::Selection([0_u32].into_iter().collect())
+ );
+ Ok(())
+ }
+
+ #[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,
+ };
+ assert!(matches!(error, Error::Unsupported { .. }));
+ Ok(())
+ }
+
#[tokio::test]
async fn test_large_data_set() -> crate::Result<()> {
let path = "memory:/tmp/test_large_data_set";
diff --git a/crates/paimon/src/file_index/file_index_reader.rs
b/crates/paimon/src/file_index/file_index_reader.rs
index afc5c472..be986099 100644
--- a/crates/paimon/src/file_index/file_index_reader.rs
+++ b/crates/paimon/src/file_index/file_index_reader.rs
@@ -34,3 +34,78 @@ pub(crate) trait FileIndexReader {
FileIndexResult::Remain
}
}
+
+/// Reader used by the outer format when a writer produced no rows.
+pub(crate) struct EmptyFileIndexReader;
+
+impl FileIndexReader for EmptyFileIndexReader {
+ fn evaluate(
+ &self,
+ _column: &str,
+ _index: usize,
+ _data_type: &DataType,
+ operator: PredicateOperator,
+ _literals: &[Datum],
+ ) -> FileIndexResult {
+ match operator {
+ PredicateOperator::Eq
+ | PredicateOperator::Lt
+ | PredicateOperator::LtEq
+ | PredicateOperator::Gt
+ | PredicateOperator::GtEq
+ | PredicateOperator::In
+ | PredicateOperator::IsNotNull
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains => FileIndexResult::Skip,
+ _ => FileIndexResult::Remain,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::IntType;
+
+ #[test]
+ fn test_empty_reader_matches_java_supported_predicates() {
+ let reader = EmptyFileIndexReader;
+ let data_type = DataType::Int(IntType::new());
+
+ for operator in [
+ PredicateOperator::Eq,
+ PredicateOperator::Lt,
+ PredicateOperator::LtEq,
+ PredicateOperator::Gt,
+ PredicateOperator::GtEq,
+ PredicateOperator::In,
+ PredicateOperator::IsNotNull,
+ PredicateOperator::StartsWith,
+ PredicateOperator::EndsWith,
+ PredicateOperator::Contains,
+ ] {
+ assert_eq!(
+ reader.evaluate("a", 0, &data_type, operator,
&[Datum::Int(1)]),
+ FileIndexResult::Skip
+ );
+ }
+
+ for operator in [
+ PredicateOperator::IsNull,
+ PredicateOperator::NotEq,
+ PredicateOperator::NotIn,
+ PredicateOperator::ArrayContains,
+ PredicateOperator::ArraysOverlap,
+ PredicateOperator::ArrayContainsAll,
+ PredicateOperator::Like,
+ PredicateOperator::Between,
+ PredicateOperator::NotBetween,
+ ] {
+ assert_eq!(
+ reader.evaluate("a", 0, &data_type, operator,
&[Datum::Int(1)]),
+ FileIndexResult::Remain
+ );
+ }
+ }
+}
diff --git a/crates/paimon/src/file_index/mod.rs
b/crates/paimon/src/file_index/file_index_writer.rs
similarity index 61%
copy from crates/paimon/src/file_index/mod.rs
copy to crates/paimon/src/file_index/file_index_writer.rs
index 48616abb..3fce9bb8 100644
--- a/crates/paimon/src/file_index/mod.rs
+++ b/crates/paimon/src/file_index/file_index_writer.rs
@@ -15,18 +15,19 @@
// specific language governing permissions and limitations
// under the License.
-// Concrete readers/writers and predicate plumbing stay crate-private until
-// factory, data-writer, and scan integration land in later changes.
-#[allow(dead_code)]
-pub(crate) mod bitmap;
-#[allow(dead_code)]
-pub(crate) mod bloom_filter;
-mod file_index_format;
-#[allow(dead_code)]
-pub(crate) mod file_index_predicate;
-#[allow(dead_code)]
-pub(crate) mod file_index_reader;
-#[allow(dead_code)]
-pub(crate) mod file_index_result;
+use bytes::Bytes;
-pub use file_index_format::*;
+use crate::spec::Datum;
+use crate::Result;
+
+/// Writes one concrete file index payload.
+pub(crate) trait FileIndexWriter {
+ /// Adds one row to the index. `None` represents a null value.
+ fn write(&mut self, datum: Option<&Datum>) -> Result<()>;
+
+ /// Serializes the index-specific payload consumed by a matching reader.
+ fn serialized_bytes(&mut self) -> Result<Bytes>;
+
+ /// Returns whether no rows have been added to this writer.
+ fn empty(&self) -> bool;
+}
diff --git a/crates/paimon/src/file_index/file_indexer_factory.rs
b/crates/paimon/src/file_index/file_indexer_factory.rs
new file mode 100644
index 00000000..c6fb4b4b
--- /dev/null
+++ b/crates/paimon/src/file_index/file_indexer_factory.rs
@@ -0,0 +1,169 @@
+// 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 bytes::Bytes;
+
+use crate::common::Options;
+use crate::file_index::bitmap::writer::BitmapFileIndexWriter;
+use crate::file_index::bitmap::BitmapFileIndexReader;
+use crate::file_index::bloom_filter::{BloomFilterReader, BloomFilterWriter};
+use crate::file_index::file_index_reader::FileIndexReader;
+use crate::file_index::file_index_writer::FileIndexWriter;
+use crate::spec::DataType;
+use crate::{Error, Result};
+
+pub(crate) const BITMAP_INDEX: &str = "bitmap";
+pub(crate) const BLOOM_FILTER_INDEX: &str = "bloom-filter";
+
+#[derive(Clone, Copy)]
+enum BuiltinFileIndexer {
+ Bitmap,
+ BloomFilter,
+}
+
+impl BuiltinFileIndexer {
+ fn from_identifier(identifier: &str) -> Result<Self> {
+ match identifier {
+ BITMAP_INDEX => Ok(Self::Bitmap),
+ BLOOM_FILTER_INDEX => Ok(Self::BloomFilter),
+ _ => Err(Error::Unsupported {
+ message: format!("Unknown file index identifier:
{identifier}"),
+ }),
+ }
+ }
+}
+
+/// Factory for the file index implementations built into this crate.
+pub(crate) struct FileIndexerFactory;
+
+impl FileIndexerFactory {
+ pub(crate) fn create_writer(
+ identifier: &str,
+ data_type: DataType,
+ options: &Options,
+ ) -> Result<Box<dyn FileIndexWriter>> {
+ match BuiltinFileIndexer::from_identifier(identifier)? {
+ BuiltinFileIndexer::Bitmap =>
Ok(Box::new(BitmapFileIndexWriter::try_new(
+ data_type, options,
+ )?)),
+ BuiltinFileIndexer::BloomFilter => {
+ Ok(Box::new(BloomFilterWriter::try_new(data_type, options)?))
+ }
+ }
+ }
+
+ pub(crate) fn create_reader(
+ identifier: &str,
+ data_type: DataType,
+ serialized: Bytes,
+ ) -> Result<Box<dyn FileIndexReader>> {
+ match BuiltinFileIndexer::from_identifier(identifier)? {
+ BuiltinFileIndexer::Bitmap =>
Ok(Box::new(BitmapFileIndexReader::try_new(
+ data_type, serialized,
+ )?)),
+ BuiltinFileIndexer::BloomFilter => {
+ Ok(Box::new(BloomFilterReader::try_new(data_type,
serialized)?))
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{BinaryType, BooleanType, Datum, IntType};
+
+ fn int_type() -> DataType {
+ DataType::Int(IntType::new())
+ }
+
+ #[test]
+ fn test_builtin_writers_track_empty_rows_consistently() {
+ for identifier in [BITMAP_INDEX, BLOOM_FILTER_INDEX] {
+ let mut writer =
+ FileIndexerFactory::create_writer(identifier, int_type(),
&Options::new()).unwrap();
+
+ assert!(writer.empty(), "{identifier}");
+ writer.serialized_bytes().unwrap();
+ assert!(writer.empty(), "{identifier}");
+
+ writer.write(None).unwrap();
+ assert!(!writer.empty(), "{identifier}");
+ writer.serialized_bytes().unwrap();
+ }
+ }
+
+ #[test]
+ fn test_factory_delegates_type_and_option_validation() {
+ let mut bloom_options = Options::new();
+ bloom_options.set("items", "0");
+ assert!(matches!(
+ FileIndexerFactory::create_writer(BLOOM_FILTER_INDEX, int_type(),
&bloom_options),
+ Err(Error::ConfigInvalid { .. })
+ ));
+
+ let mut bitmap_options = Options::new();
+ bitmap_options.set("version", "1");
+ assert!(matches!(
+ FileIndexerFactory::create_writer(BITMAP_INDEX, int_type(),
&bitmap_options),
+ Err(Error::Unsupported { .. })
+ ));
+
+ assert!(matches!(
+ FileIndexerFactory::create_writer(
+ BITMAP_INDEX,
+ DataType::Binary(BinaryType::new(4).unwrap()),
+ &Options::new()
+ ),
+ Err(Error::Unsupported { .. })
+ ));
+ assert!(matches!(
+ FileIndexerFactory::create_writer(
+ BLOOM_FILTER_INDEX,
+ DataType::Boolean(BooleanType::new()),
+ &Options::new()
+ ),
+ Err(Error::Unsupported { .. })
+ ));
+ }
+
+ #[test]
+ fn test_unknown_identifier_is_rejected() {
+ assert!(matches!(
+ FileIndexerFactory::create_writer("unknown", int_type(),
&Options::new()),
+ Err(Error::Unsupported { .. })
+ ));
+ assert!(matches!(
+ FileIndexerFactory::create_reader("unknown", int_type(),
Bytes::new()),
+ Err(Error::Unsupported { .. })
+ ));
+ }
+
+ #[test]
+ fn test_writer_rejects_mismatched_datum() {
+ for identifier in [BITMAP_INDEX, BLOOM_FILTER_INDEX] {
+ let mut writer =
+ FileIndexerFactory::create_writer(identifier, int_type(),
&Options::new()).unwrap();
+
+ assert!(matches!(
+ writer.write(Some(&Datum::Long(1))),
+ Err(Error::DataInvalid { .. })
+ ));
+ assert!(writer.empty(), "{identifier}");
+ }
+ }
+}
diff --git a/crates/paimon/src/file_index/mod.rs
b/crates/paimon/src/file_index/mod.rs
index 48616abb..c070ebdc 100644
--- a/crates/paimon/src/file_index/mod.rs
+++ b/crates/paimon/src/file_index/mod.rs
@@ -16,7 +16,7 @@
// under the License.
// Concrete readers/writers and predicate plumbing stay crate-private until
-// factory, data-writer, and scan integration land in later changes.
+// data-writer and scan integration land in later changes.
#[allow(dead_code)]
pub(crate) mod bitmap;
#[allow(dead_code)]
@@ -28,5 +28,9 @@ pub(crate) mod file_index_predicate;
pub(crate) mod file_index_reader;
#[allow(dead_code)]
pub(crate) mod file_index_result;
+#[allow(dead_code)]
+pub(crate) mod file_index_writer;
+#[allow(dead_code)]
+pub(crate) mod file_indexer_factory;
pub use file_index_format::*;