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 d09cb1ac feat(btree): add optional bloom filters (#869)
d09cb1ac is described below

commit d09cb1ac3411d53b7259ef333cfc7f363afb3c39
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Sep 18 11:41:36 2026 +0800

    feat(btree): add optional bloom filters (#869)
---
 crates/paimon/src/btree/bloom_filter.rs            | 186 +++++++++++++++++++++
 crates/paimon/src/btree/footer.rs                  |   2 +
 crates/paimon/src/btree/mod.rs                     |   1 +
 crates/paimon/src/btree/reader.rs                  | 102 ++++++++++-
 crates/paimon/src/btree/sst_file.rs                |  41 +++++
 crates/paimon/src/btree/tests.rs                   | 117 +++++++++++++
 crates/paimon/src/btree/writer.rs                  |  28 +++-
 crates/paimon/src/spec/murmur_hash.rs              |  26 +++
 .../sorted_global_index_build_builder/tests.rs     |  26 ++-
 .../sorted_global_index_build_builder/writer.rs    |   3 +-
 .../src/table/sorted_global_index_options.rs       |  39 +++++
 docs/src/sql.md                                    |   8 +-
 12 files changed, 567 insertions(+), 12 deletions(-)

diff --git a/crates/paimon/src/btree/bloom_filter.rs 
b/crates/paimon/src/btree/bloom_filter.rs
new file mode 100644
index 00000000..3652d269
--- /dev/null
+++ b/crates/paimon/src/btree/bloom_filter.rs
@@ -0,0 +1,186 @@
+// 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.
+
+//! Bloom filter used by Java-compatible BTree SST files.
+
+use bytes::Bytes;
+use std::io;
+
+const BITS_PER_BYTE: usize = u8::BITS as usize;
+
+/// Bloom filter with its hash count derived from the expected entry count and 
serialized size.
+pub(super) struct BloomFilter {
+    expected_entries: u64,
+    bits: BitSet,
+    num_hash_functions: i32,
+}
+
+enum BitSet {
+    Mutable(Vec<u8>),
+    Shared(Bytes),
+}
+
+impl BloomFilter {
+    pub(super) fn from_hashes(hashes: &[i32], fpp: f64) -> 
io::Result<Option<Self>> {
+        if hashes.is_empty() {
+            return Ok(None);
+        }
+        if !fpp.is_finite() || fpp <= 0.0 || fpp >= 1.0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                format!("Bloom filter fpp must be finite and in (0, 1), but 
was {fpp}"),
+            ));
+        }
+
+        let expected_entries = hashes.len() as u64;
+        let log_two = 2.0_f64.ln();
+        // Java first narrows the optimal bit count to int, then rounds the 
byte count up.
+        let optimal_bits = (-(expected_entries as f64) * fpp.ln() / (log_two * 
log_two)) as i32;
+        let byte_size = (f64::from(optimal_bits) / BITS_PER_BYTE as 
f64).ceil() as usize;
+        if byte_size == 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                format!(
+                    "Bloom filter size must be positive for {expected_entries} 
entries and fpp {fpp}"
+                ),
+            ));
+        }
+
+        let mut bits = Vec::new();
+        bits.try_reserve_exact(byte_size).map_err(|error| {
+            io::Error::other(format!("Failed to allocate Bloom filter: 
{error}"))
+        })?;
+        bits.resize(byte_size, 0);
+        let num_hash_functions = num_hash_functions(expected_entries, 
bits.len())?;
+        let mut filter = Self {
+            expected_entries,
+            bits: BitSet::Mutable(bits),
+            num_hash_functions,
+        };
+        for &hash in hashes {
+            filter.add_hash(hash);
+        }
+        Ok(Some(filter))
+    }
+
+    pub(super) fn from_bytes(expected_entries: u64, bits: Bytes) -> 
io::Result<Self> {
+        if expected_entries == 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                "Bloom filter expected entry count must be positive",
+            ));
+        }
+        if bits.is_empty() {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                "Bloom filter bitset must not be empty",
+            ));
+        }
+
+        let num_hash_functions = num_hash_functions(expected_entries, 
bits.len())?;
+        Ok(Self {
+            expected_entries,
+            bits: BitSet::Shared(bits),
+            num_hash_functions,
+        })
+    }
+
+    pub(super) fn expected_entries(&self) -> u64 {
+        self.expected_entries
+    }
+
+    pub(super) fn bytes(&self) -> &[u8] {
+        self.bits.bytes()
+    }
+
+    pub(super) fn test_hash(&self, hash: i32) -> bool {
+        (1..=self.num_hash_functions).all(|iteration| 
self.is_set(self.position(hash, iteration)))
+    }
+
+    fn add_hash(&mut self, hash: i32) {
+        for iteration in 1..=self.num_hash_functions {
+            let position = self.position(hash, iteration);
+            match &mut self.bits {
+                BitSet::Mutable(bits) => bits[position >> 3] |= 1 << (position 
& 7),
+                BitSet::Shared(_) => unreachable!("serialized Bloom filter is 
immutable"),
+            }
+        }
+    }
+
+    fn position(&self, hash: i32, iteration: i32) -> usize {
+        let hash2 = ((hash as u32) >> 16) as i32;
+        let mut combined = hash.wrapping_add(iteration.wrapping_mul(hash2));
+        if combined < 0 {
+            combined = !combined;
+        }
+        combined as usize % (self.bits.bytes().len() * BITS_PER_BYTE)
+    }
+
+    fn is_set(&self, position: usize) -> bool {
+        self.bits.bytes()[position >> 3] & (1 << (position & 7)) != 0
+    }
+}
+
+impl BitSet {
+    fn bytes(&self) -> &[u8] {
+        match self {
+            Self::Mutable(bits) => bits,
+            Self::Shared(bits) => bits,
+        }
+    }
+}
+
+fn num_hash_functions(expected_entries: u64, byte_size: usize) -> 
io::Result<i32> {
+    let bit_size = byte_size.checked_mul(BITS_PER_BYTE).ok_or_else(|| {
+        io::Error::new(
+            io::ErrorKind::InvalidData,
+            "Bloom filter bit size overflows",
+        )
+    })?;
+    Ok(
+        (((bit_size as f64 / expected_entries as f64) * 2.0_f64.ln())
+            .round()
+            .max(1.0)) as i32,
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::murmur_hash::hash_bytes;
+
+    #[test]
+    fn test_java_compatible_bit_layout() {
+        let hashes = [hash_bytes(b"a"), hash_bytes(b"hello"), 
hash_bytes(b"world")];
+        let filter = BloomFilter::from_hashes(&hashes, 0.05).unwrap().unwrap();
+
+        assert_eq!(filter.expected_entries(), 3);
+        assert_eq!(filter.bytes(), &[0xea, 0x22, 0x99]);
+        assert!(hashes.into_iter().all(|hash| filter.test_hash(hash)));
+    }
+
+    #[test]
+    fn test_empty_hashes_do_not_create_filter() {
+        assert!(BloomFilter::from_hashes(&[], 0.05).unwrap().is_none());
+    }
+
+    #[test]
+    fn test_rejects_invalid_serialized_filter() {
+        assert!(BloomFilter::from_bytes(0, Bytes::from_static(&[1])).is_err());
+        assert!(BloomFilter::from_bytes(1, Bytes::new()).is_err());
+    }
+}
diff --git a/crates/paimon/src/btree/footer.rs 
b/crates/paimon/src/btree/footer.rs
index 449e95d6..027802a7 100644
--- a/crates/paimon/src/btree/footer.rs
+++ b/crates/paimon/src/btree/footer.rs
@@ -50,6 +50,8 @@ pub struct BTreeFileFooter {
 }
 
 impl BTreeFileFooter {
+    pub const ENCODED_LENGTH: usize = BTREE_FOOTER_ENCODED_LENGTH;
+
     pub fn new(
         bloom_filter_handle: Option<BloomFilterHandle>,
         index_block_handle: BlockHandle,
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index ad66ddfe..6cc4ea01 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -37,6 +37,7 @@
 //! ```
 
 mod block;
+mod bloom_filter;
 mod footer;
 pub(crate) mod key_serde;
 mod meta;
diff --git a/crates/paimon/src/btree/reader.rs 
b/crates/paimon/src/btree/reader.rs
index 11d48ebd..7f8f1ba1 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -24,14 +24,22 @@
 //! - IN / NOT IN queries
 
 use crate::btree::block::{BlockHandle, BlockReader};
-use crate::btree::footer::{BTreeFileFooter, BTREE_FOOTER_ENCODED_LENGTH};
+use crate::btree::bloom_filter::BloomFilter;
+use crate::btree::footer::{BTreeFileFooter, BloomFilterHandle, 
BTREE_FOOTER_ENCODED_LENGTH};
 use crate::btree::meta::BTreeIndexMeta;
 use crate::btree::sst_file::{read_block_from_bytes, SstFileReader};
 use crate::btree::var_len::{decode_var_int, decode_var_long};
 use crate::io::FileRead;
+use crate::spec::murmur_hash::hash_bytes;
 use roaring::RoaringTreemap;
 use std::cmp::Ordering;
 use std::io::{self, Cursor};
+use tokio::sync::OnceCell;
+
+struct LazyBloomFilter {
+    handle: BloomFilterHandle,
+    filter: OnceCell<BloomFilter>,
+}
 
 /// BTree index reader with on-demand async data block loading.
 pub struct BTreeIndexReader<F: Fn(&[u8], &[u8]) -> Ordering> {
@@ -41,6 +49,7 @@ pub struct BTreeIndexReader<F: Fn(&[u8], &[u8]) -> Ordering> {
     min_key: Option<Vec<u8>>,
     max_key: Option<Vec<u8>>,
     key_comparator: F,
+    bloom_filter: Option<LazyBloomFilter>,
 }
 
 impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
@@ -67,6 +76,10 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
             .await
             .map_err(|e| io::Error::other(e.to_string()))?;
         let footer = BTreeFileFooter::read_footer(&footer_bytes)?;
+        let bloom_filter = footer.bloom_filter_handle.map(|handle| 
LazyBloomFilter {
+            handle,
+            filter: OnceCell::new(),
+        });
 
         // 2. Read index block
         let idx = &footer.index_block_handle;
@@ -91,6 +104,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
             min_key: meta.first_key.clone(),
             max_key: meta.last_key.clone(),
             key_comparator,
+            bloom_filter,
         })
     }
 
@@ -248,9 +262,81 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
         read_block_from_bytes(&bytes, handle.size)
     }
 
+    async fn bloom_might_contain(&self, key: &[u8]) -> io::Result<bool> {
+        let Some(lazy) = &self.bloom_filter else {
+            return Ok(true);
+        };
+        let filter = lazy
+            .filter
+            .get_or_try_init(|| async {
+                if lazy.handle.size == 0 || lazy.handle.expected_entries == 0 {
+                    return Err(io::Error::new(
+                        io::ErrorKind::InvalidData,
+                        "BTree Bloom filter handle must have positive size and 
expected entries",
+                    ));
+                }
+                let end = lazy
+                    .handle
+                    .offset
+                    .checked_add(u64::from(lazy.handle.size))
+                    .ok_or_else(|| {
+                        io::Error::new(
+                            io::ErrorKind::InvalidData,
+                            "BTree Bloom filter range overflows",
+                        )
+                    })?;
+                let bits = self
+                    .reader
+                    .read(lazy.handle.offset..end)
+                    .await
+                    .map_err(|error| io::Error::other(error.to_string()))?;
+                if bits.len() != lazy.handle.size as usize {
+                    return Err(io::Error::new(
+                        io::ErrorKind::UnexpectedEof,
+                        format!(
+                            "BTree Bloom filter expected {} bytes, read {}",
+                            lazy.handle.size,
+                            bits.len()
+                        ),
+                    ));
+                }
+                BloomFilter::from_bytes(lazy.handle.expected_entries, bits)
+            })
+            .await?;
+        Ok(filter.test_hash(hash_bytes(key)))
+    }
+
     /// Equal query: returns row ids for the given key.
     pub async fn query_equal(&self, key: &[u8]) -> io::Result<RoaringTreemap> {
-        self.range_query(key, key, true, true).await
+        let cmp = &self.key_comparator;
+        if self
+            .min_key
+            .as_deref()
+            .is_none_or(|min| cmp(key, min) == Ordering::Less)
+            || self
+                .max_key
+                .as_deref()
+                .is_none_or(|max| cmp(key, max) == Ordering::Greater)
+        {
+            return Ok(RoaringTreemap::new());
+        }
+        if !self.bloom_might_contain(key).await? {
+            return Ok(RoaringTreemap::new());
+        }
+
+        let index_block = self.sst_reader.index_block();
+        let (_, mut index_iter) = index_block.seek_and_iter(key, cmp);
+        let Some((_last_key, handle_bytes)) = index_iter.next() else {
+            return Ok(RoaringTreemap::new());
+        };
+        let handle = BlockHandle::decode(handle_bytes)?;
+        let block = self.read_data_block(&handle).await?;
+        let (found, mut entry_iter) = block.seek_and_iter(key, cmp);
+        let mut result = RoaringTreemap::new();
+        if let (true, Some((_entry_key, value))) = (found, entry_iter.next()) {
+            insert_row_ids_into(value, &mut result)?;
+        }
+        Ok(result)
     }
 
     /// Less than query.
@@ -334,11 +420,21 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> 
{
             return Ok(RoaringTreemap::new());
         }
 
+        let mut bloom_matches = Vec::with_capacity(sorted_keys.len());
+        for key in sorted_keys {
+            if self.bloom_might_contain(key).await? {
+                bloom_matches.push(key);
+            }
+        }
+        if bloom_matches.is_empty() {
+            return Ok(RoaringTreemap::new());
+        }
+
         // The index block is already resident in memory. Resolve every target 
key to its first
         // possible data block and coalesce adjacent targets that share the 
same block handle.
         let index_block = self.sst_reader.index_block();
         let mut target_blocks: Vec<(BlockHandle, Vec<&[u8]>)> = Vec::new();
-        for key in sorted_keys {
+        for key in bloom_matches {
             let (_, mut index_iter) = index_block.seek_and_iter(key, cmp);
             let Some((_last_key, handle_bytes)) = index_iter.next() else {
                 break;
diff --git a/crates/paimon/src/btree/sst_file.rs 
b/crates/paimon/src/btree/sst_file.rs
index 988c32c8..5695a21a 100644
--- a/crates/paimon/src/btree/sst_file.rs
+++ b/crates/paimon/src/btree/sst_file.rs
@@ -21,7 +21,10 @@ use crate::btree::block::{
     compress_block, compute_crc32, decompress_block, BlockCompressionType, 
BlockHandle,
     BlockReader, BlockTrailer, BlockWriter, BLOCK_HANDLE_MAX_ENCODED_LENGTH, 
BLOCK_TRAILER_LENGTH,
 };
+use crate::btree::bloom_filter::BloomFilter;
+use crate::btree::footer::BloomFilterHandle;
 use crate::io::FileWrite;
+use crate::spec::murmur_hash::hash_bytes;
 use bytes::Bytes;
 use std::io;
 
@@ -42,6 +45,7 @@ pub struct SstFileWriter {
     compression_level: i32,
     last_key: Option<Vec<u8>>,
     record_count: u64,
+    bloom_hashes: Option<Vec<i32>>,
 }
 
 impl SstFileWriter {
@@ -70,9 +74,24 @@ impl SstFileWriter {
             compression_level,
             last_key: None,
             record_count: 0,
+            bloom_hashes: None,
         }
     }
 
+    /// Create a writer with optional Bloom filter collection.
+    pub(super) fn with_bloom_filter(
+        writer: Box<dyn FileWrite>,
+        block_size: usize,
+        compression_type: BlockCompressionType,
+        compression_level: i32,
+        bloom_filter_enabled: bool,
+    ) -> Self {
+        let mut writer =
+            Self::with_compression_level(writer, block_size, compression_type, 
compression_level);
+        writer.bloom_hashes = bloom_filter_enabled.then(Vec::new);
+        writer
+    }
+
     /// Current write position in the output.
     pub fn position(&self) -> u64 {
         self.bytes_written
@@ -91,6 +110,9 @@ impl SstFileWriter {
     /// Put a key-value pair. Keys must be monotonically increasing.
     pub async fn put(&mut self, key: &[u8], value: &[u8]) -> io::Result<()> {
         self.data_block_writer.add(key, value);
+        if let Some(hashes) = &mut self.bloom_hashes {
+            hashes.push(hash_bytes(key));
+        }
 
         // Only clone key if it changed
         match &self.last_key {
@@ -161,6 +183,25 @@ impl SstFileWriter {
         Ok(block_handle)
     }
 
+    /// Build and write the optional Bloom filter from the keys collected by 
[`Self::put`].
+    pub async fn write_bloom_filter(&mut self, fpp: f64) -> 
io::Result<Option<BloomFilterHandle>> {
+        let Some(hashes) = self.bloom_hashes.take() else {
+            return Ok(None);
+        };
+        let Some(filter) = BloomFilter::from_hashes(&hashes, fpp)? else {
+            return Ok(None);
+        };
+        let handle = BloomFilterHandle {
+            offset: self.position(),
+            size: u32::try_from(filter.bytes().len()).map_err(|_| {
+                io::Error::new(io::ErrorKind::InvalidData, "Bloom filter 
exceeds u32 size")
+            })?,
+            expected_entries: filter.expected_entries(),
+        };
+        self.write_bytes(filter.bytes()).await?;
+        Ok(Some(handle))
+    }
+
     /// Write raw bytes (e.g., footer, null bitmap).
     pub async fn write_raw(&mut self, data: &[u8]) -> io::Result<()> {
         self.write_bytes(data).await
diff --git a/crates/paimon/src/btree/tests.rs b/crates/paimon/src/btree/tests.rs
index 252ff883..89ac793b 100644
--- a/crates/paimon/src/btree/tests.rs
+++ b/crates/paimon/src/btree/tests.rs
@@ -16,12 +16,15 @@
 // under the License.
 
 use crate::btree::block::BlockCompressionType;
+use crate::btree::bloom_filter::BloomFilter;
+use crate::btree::footer::{BTreeFileFooter, BTREE_FOOTER_ENCODED_LENGTH};
 use crate::btree::meta::BTreeIndexMeta;
 use crate::btree::query::IndexQuery;
 use crate::btree::reader::BTreeIndexReader;
 use crate::btree::test_util::{BytesFileRead, VecFileWrite};
 use crate::btree::writer::BTreeIndexWriter;
 use crate::io::FileRead;
+use crate::spec::murmur_hash::hash_bytes;
 use crate::spec::{DataType, Datum, PredicateOperator, VarCharType};
 use bytes::Bytes;
 use std::ops::Range;
@@ -91,6 +94,120 @@ async fn test_write_read_roundtrip() {
     assert_eq!(all.len(), 100);
 }
 
+fn read_footer(data: &[u8]) -> BTreeFileFooter {
+    BTreeFileFooter::read_footer(&data[data.len() - 
BTREE_FOOTER_ENCODED_LENGTH..]).unwrap()
+}
+
+#[tokio::test]
+async fn test_bloom_filter_is_disabled_by_default() {
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 64, 
BlockCompressionType::None);
+    writer.write(Some(&int_key(1)), 0).await.unwrap();
+    writer.finish().await.unwrap();
+
+    assert!(read_footer(&buf.to_vec()).bloom_filter_handle.is_none());
+}
+
+#[tokio::test]
+async fn test_bloom_filter_skips_missing_point_lookup_data_reads() {
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::with_comparator_and_options(
+        Box::new(buf.clone()),
+        64,
+        BlockCompressionType::None,
+        1,
+        true,
+        int_cmp,
+    );
+    for i in 0..1000 {
+        let key = int_key(i * 2);
+        writer.write(Some(&key), i as i64).await.unwrap();
+        if i == 4 {
+            writer.write(Some(&key), 1000).await.unwrap();
+        }
+    }
+    let write_result = writer.finish().await.unwrap();
+    let data = Bytes::from(buf.to_vec());
+    let footer = read_footer(&data);
+    let handle = footer
+        .bloom_filter_handle
+        .expect("enabled non-empty index must write a Bloom filter");
+    assert_eq!(handle.expected_entries, 1000, "one entry per distinct key");
+
+    let bloom = BloomFilter::from_bytes(
+        handle.expected_entries,
+        data.slice(handle.offset as usize..handle.offset as usize + 
handle.size as usize),
+    )
+    .unwrap();
+    let missing_key = (1..2000)
+        .step_by(2)
+        .map(int_key)
+        .find(|key| !bloom.test_hash(hash_bytes(key)))
+        .expect("test data should contain a Bloom-negative missing key");
+
+    for use_in in [false, true] {
+        let ranges = Arc::new(Mutex::new(Vec::new()));
+        let reader = BTreeIndexReader::open(
+            Box::new(RecordingFileRead {
+                data: data.clone(),
+                ranges: ranges.clone(),
+            }),
+            data.len() as u64,
+            &write_result.meta,
+            int_cmp,
+        )
+        .await
+        .unwrap();
+        ranges.lock().unwrap().clear();
+
+        let rows = if use_in {
+            reader.query_in(&[missing_key.as_slice()]).await.unwrap()
+        } else {
+            reader.query_equal(&missing_key).await.unwrap()
+        };
+        assert!(rows.is_empty());
+        assert_eq!(
+            *ranges.lock().unwrap(),
+            vec![handle.offset..handle.offset + u64::from(handle.size)]
+        );
+    }
+
+    let ranges = Arc::new(Mutex::new(Vec::new()));
+    let reader = BTreeIndexReader::open(
+        Box::new(RecordingFileRead {
+            data: data.clone(),
+            ranges: ranges.clone(),
+        }),
+        data.len() as u64,
+        &write_result.meta,
+        int_cmp,
+    )
+    .await
+    .unwrap();
+    ranges.lock().unwrap().clear();
+    let in_keys = [missing_key.as_slice()];
+    let (equal_rows, in_rows) =
+        tokio::join!(reader.query_equal(&missing_key), 
reader.query_in(&in_keys));
+    assert!(equal_rows.unwrap().is_empty());
+    assert!(in_rows.unwrap().is_empty());
+    assert_eq!(
+        *ranges.lock().unwrap(),
+        vec![handle.offset..handle.offset + u64::from(handle.size)],
+        "concurrent point lookups should share one lazy Bloom read"
+    );
+
+    let reader = write_and_open(&buf, &write_result, int_cmp).await;
+    assert_eq!(
+        reader
+            .query_equal(&int_key(8))
+            .await
+            .unwrap()
+            .iter()
+            .collect::<Vec<_>>(),
+        vec![4, 1000]
+    );
+}
+
 #[tokio::test]
 async fn test_duplicate_keys() {
     let buf = VecFileWrite::new();
diff --git a/crates/paimon/src/btree/writer.rs 
b/crates/paimon/src/btree/writer.rs
index aa6ab2bb..6743174f 100644
--- a/crates/paimon/src/btree/writer.rs
+++ b/crates/paimon/src/btree/writer.rs
@@ -31,6 +31,8 @@ use roaring::RoaringTreemap;
 use std::cmp::Ordering;
 use std::io;
 
+const BLOOM_FILTER_FPP: f64 = 0.05;
+
 /// BTree index writer. Writes sorted key -> row_id_list entries into a BTree 
index file.
 ///
 /// Usage:
@@ -103,13 +105,33 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexWriter<F> 
{
         compression_type: BlockCompressionType,
         compression_level: i32,
         cmp: F,
+    ) -> Self {
+        Self::with_comparator_and_options(
+            writer,
+            block_size,
+            compression_type,
+            compression_level,
+            false,
+            cmp,
+        )
+    }
+
+    /// Create a writer with all Java-compatible BTree file options.
+    pub fn with_comparator_and_options(
+        writer: Box<dyn FileWrite>,
+        block_size: usize,
+        compression_type: BlockCompressionType,
+        compression_level: i32,
+        bloom_filter_enabled: bool,
+        cmp: F,
     ) -> Self {
         Self {
-            sst_writer: SstFileWriter::with_compression_level(
+            sst_writer: SstFileWriter::with_bloom_filter(
                 writer,
                 block_size,
                 compression_type,
                 compression_level,
+                bloom_filter_enabled,
             ),
             current_row_ids: Vec::new(),
             last_key: None,
@@ -208,8 +230,8 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexWriter<F> {
         // Write null bitmap
         let null_bitmap_handle = self.write_null_bitmap().await?;
 
-        // No bloom filter for now (same as Java: todo)
-        let bloom_filter_handle = None;
+        // Write the optional Bloom filter after the null bitmap and before 
the index block.
+        let bloom_filter_handle = 
self.sst_writer.write_bloom_filter(BLOOM_FILTER_FPP).await?;
 
         // Write index block
         let index_block_handle = self.sst_writer.write_index_block().await?;
diff --git a/crates/paimon/src/spec/murmur_hash.rs 
b/crates/paimon/src/spec/murmur_hash.rs
index 3029e67b..08f228ce 100644
--- a/crates/paimon/src/spec/murmur_hash.rs
+++ b/crates/paimon/src/spec/murmur_hash.rs
@@ -68,6 +68,24 @@ pub fn hash_by_words(data: &[u8]) -> i32 {
     fmix(h1 ^ data.len() as u32) as i32
 }
 
+/// Murmur3 32-bit hash over arbitrary bytes.
+///
+/// This matches Java Paimon's `MurmurHashUtils.hashBytes` with `DEFAULT_SEED 
= 42`.
+/// In particular, Java mixes each trailing signed byte independently instead 
of combining the
+/// tail bytes into one word as the canonical Murmur3 algorithm does.
+pub(crate) fn hash_bytes(data: &[u8]) -> i32 {
+    let (words, tail) = data.as_chunks::<4>();
+    let mut h1 = DEFAULT_SEED;
+    for word in words {
+        h1 = mix_h1(h1, mix_k1(u32::from_le_bytes(*word)));
+    }
+    for byte in tail {
+        let signed = i32::from(*byte as i8) as u32;
+        h1 = mix_h1(h1, mix_k1(signed));
+    }
+    fmix(h1 ^ data.len() as u32) as i32
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -89,6 +107,14 @@ mod tests {
         assert_eq!(h, expected);
     }
 
+    #[test]
+    fn test_hash_bytes_matches_java() {
+        assert_eq!(hash_bytes(b"a"), 1_485_273_170);
+        assert_eq!(hash_bytes(b"hello"), -1_008_564_952);
+        assert_eq!(hash_bytes(b"world"), -623_458_850);
+        assert_eq!(hash_bytes(&[0xff]), 1_398_487_324);
+    }
+
     #[test]
     #[should_panic(expected = "word-aligned")]
     fn test_hash_non_aligned_panics() {
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
index db58c4bc..fff014d5 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/tests.rs
@@ -16,7 +16,7 @@
 // under the License.
 
 use super::*;
-use crate::btree::BTreeIndexMeta;
+use crate::btree::{BTreeFileFooter, BTreeIndexMeta};
 use crate::catalog::Identifier;
 use crate::io::FileIOBuilder;
 use crate::spec::stats::BinaryTableStats;
@@ -677,7 +677,12 @@ async fn scan_ids(table: &Table, predicate: Predicate) -> 
Vec<i32> {
 #[tokio::test]
 async fn test_execute_writes_btree_index_manifest_and_file() {
     let table_path = "memory:/test_btree_global_index_builder_e2e";
-    let table = test_table_with_path(table_path, table_options("10"));
+    let mut options = table_options("10");
+    options.insert(
+        "btree-index.bloom-filter.enabled".to_string(),
+        "true".to_string(),
+    );
+    let table = test_table_with_path(table_path, options);
     setup_dirs(&table).await;
 
     let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
@@ -721,6 +726,23 @@ async fn 
test_execute_writes_btree_index_manifest_and_file() {
     assert_eq!(index_file.row_count, 3);
     assert!(index_file.file_size > 0);
 
+    let index_path = format!("{table_path}/index/{}", index_file.file_name);
+    let index_bytes = table
+        .file_io()
+        .new_input(&index_path)
+        .unwrap()
+        .read()
+        .await
+        .unwrap();
+    let footer = BTreeFileFooter::read_footer(
+        &index_bytes[index_bytes.len() - BTreeFileFooter::ENCODED_LENGTH..],
+    )
+    .unwrap();
+    let bloom_handle = footer
+        .bloom_filter_handle
+        .expect("enabled BTree build must write a Bloom filter");
+    assert_eq!(bloom_handle.expected_entries, 2);
+
     let global_meta = index_file
         .global_index_meta
         .as_ref()
diff --git 
a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
index 8e412c07..1b7996ae 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/writer.rs
@@ -105,11 +105,12 @@ impl SortedGlobalIndexBuildBuilder<'_> {
                     let GlobalIndexWriteOptions::Sorted(write_options) = 
write_options else {
                         unreachable!("BTree uses sorted write options")
                     };
-                    let mut writer = 
BTreeIndexWriter::with_comparator_and_compression_level(
+                    let mut writer = 
BTreeIndexWriter::with_comparator_and_options(
                         writer,
                         write_options.block_size,
                         write_options.compression_type,
                         write_options.compression_level,
+                        write_options.bloom_filter_enabled,
                         cmp,
                     );
                     for (key, local_row_id) in &rows {
diff --git a/crates/paimon/src/table/sorted_global_index_options.rs 
b/crates/paimon/src/table/sorted_global_index_options.rs
index 982a95cd..e7a16422 100644
--- a/crates/paimon/src/table/sorted_global_index_options.rs
+++ b/crates/paimon/src/table/sorted_global_index_options.rs
@@ -24,6 +24,7 @@ use crate::{Error, Result};
 use std::collections::HashMap;
 
 pub(crate) const BTREE_BLOCK_SIZE_OPTION: &str = "btree-index.block-size";
+pub(crate) const BTREE_BLOOM_FILTER_ENABLED_OPTION: &str = 
"btree-index.bloom-filter.enabled";
 pub(crate) const BTREE_COMPRESSION_OPTION: &str = "btree-index.compression";
 pub(crate) const BTREE_COMPRESSION_LEVEL_OPTION: &str = 
"btree-index.compression-level";
 pub(crate) const BITMAP_DICTIONARY_BLOCK_SIZE_OPTION: &str = 
"bitmap-index.dictionary-block-size";
@@ -43,6 +44,7 @@ pub(crate) struct SortedIndexWriteOptions {
     pub(crate) block_size: usize,
     pub(crate) compression_type: BlockCompressionType,
     pub(crate) compression_level: i32,
+    pub(crate) bloom_filter_enabled: bool,
 }
 
 impl SortedIndexWriteOptions {
@@ -81,10 +83,29 @@ impl SortedIndexWriteOptions {
                 compression_level_option,
                 DEFAULT_COMPRESSION_LEVEL,
             )?,
+            bloom_filter_enabled: if index_type == BTREE_GLOBAL_INDEX_TYPE {
+                parse_boolean(options, BTREE_BLOOM_FILTER_ENABLED_OPTION, 
false)?
+            } else {
+                false
+            },
         })
     }
 }
 
+fn parse_boolean(options: &HashMap<String, String>, option: &str, default: 
bool) -> Result<bool> {
+    let Some(raw) = options.get(option) else {
+        return Ok(default);
+    };
+    match raw.trim().to_ascii_lowercase().as_str() {
+        "true" => Ok(true),
+        "false" => Ok(false),
+        _ => Err(Error::DataInvalid {
+            message: format!("Option '{option}' must be true or false, got: 
{raw}"),
+            source: None,
+        }),
+    }
+}
+
 fn parse_block_size(
     options: &HashMap<String, String>,
     option: &str,
@@ -163,6 +184,7 @@ mod tests {
                 block_size: 64 * 1024,
                 compression_type: BlockCompressionType::None,
                 compression_level: 1,
+                bloom_filter_enabled: false,
             }
         );
         for index_type in [BITMAP_GLOBAL_INDEX_TYPE, 
MULTIVALUE_GLOBAL_INDEX_TYPE] {
@@ -172,6 +194,7 @@ mod tests {
                     block_size: 16 * 1024,
                     compression_type: BlockCompressionType::None,
                     compression_level: 1,
+                    bloom_filter_enabled: false,
                 }
             );
         }
@@ -210,9 +233,20 @@ mod tests {
                     block_size: 32 * 1024,
                     compression_type: BlockCompressionType::Lz4,
                     compression_level: 7,
+                    bloom_filter_enabled: false,
                 }
             );
         }
+
+        let options = HashMap::from([(
+            BTREE_BLOOM_FILTER_ENABLED_OPTION.to_string(),
+            " TrUe ".to_string(),
+        )]);
+        assert!(
+            SortedIndexWriteOptions::from_options(BTREE_GLOBAL_INDEX_TYPE, 
&options)
+                .unwrap()
+                .bloom_filter_enabled
+        );
     }
 
     #[test]
@@ -234,6 +268,11 @@ mod tests {
                 BTREE_COMPRESSION_LEVEL_OPTION,
                 "fast",
             ),
+            (
+                BTREE_GLOBAL_INDEX_TYPE,
+                BTREE_BLOOM_FILTER_ENABLED_OPTION,
+                "yes",
+            ),
         ] {
             let options = HashMap::from([(option.to_string(), 
value.to_string())]);
             assert!(SortedIndexWriteOptions::from_options(index_type, 
&options).is_err());
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 85ff5b3c..76b56034 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1077,7 +1077,7 @@ CALL sys.create_global_index(
   table => 'paimon.my_db.my_table',
   index_column => 'id',
   index_type => 'btree',
-  options => 
'btree-index.block-size=64kb,btree-index.compression=zstd,btree-index.compression-level=1'
+  options => 
'btree-index.block-size=64kb,btree-index.bloom-filter.enabled=true,btree-index.compression=zstd,btree-index.compression-level=1'
 );
 
 CALL sys.create_global_index(
@@ -1114,8 +1114,9 @@ row are indexed once. All three sorted index types accept
 are `btree-index.block-size`, `bitmap-index.dictionary-block-size`, or
 `multivalue-index.dictionary-block-size`, together with the corresponding
 `*.compression` (`none`, `zstd`, `lz4`, or `lzo`) and `*.compression-level`
-options. Per-call options override table options. Bitmap and multivalue global
-indexes use Java-compatible bitmap files.
+options. BTree additionally accepts `btree-index.bloom-filter.enabled` (default
+`false`) to accelerate equality and `IN` lookups. Per-call options override 
table
+options. Bitmap and multivalue global indexes use Java-compatible bitmap files.
 
 FM global indexes support character-string columns and exact byte-substring
 `contains`, `IS NULL`, and `IS NOT NULL` predicates. They use the
@@ -2404,6 +2405,7 @@ deletion vectors enabled.
 | `global-index.row-count-per-shard` | `100000` | Maximum row count per vector 
global-index shard. |
 | `sorted-index.records-per-range` | `100000` | Maximum row count per BTree, 
bitmap, multivalue, or FM global-index file range; falls back to legacy 
`btree-index.records-per-range`. |
 | `btree-index.block-size` | `64kb` | Target BTree data-block size. |
+| `btree-index.bloom-filter.enabled` | `false` | Writes a Bloom filter used to 
avoid BTree data-block reads for missing equality and `IN` keys. |
 | `btree-index.compression` | `none` | BTree block compression: `none`, 
`zstd`, `lz4`, or `lzo`. |
 | `btree-index.compression-level` | `1` | BTree compression level (used by 
codecs that support levels). |
 | `bitmap-index.dictionary-block-size` | `16kb` | Target bitmap 
dictionary-block size. |

Reply via email to