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 afc8248f perf: optimize scalar index reads and support BTree V2 (#888)
afc8248f is described below

commit afc8248fe4a76ec1c857a2747260b7e595e908c6
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Sep 20 20:02:12 2026 +0800

    perf: optimize scalar index reads and support BTree V2 (#888)
---
 crates/integrations/datafusion/tests/procedures.rs |  31 +-
 crates/paimon/src/arrow/format/row.rs              | 127 +++-
 crates/paimon/src/btree/footer.rs                  |   7 +
 crates/paimon/src/btree/mod.rs                     |   1 +
 crates/paimon/src/btree/posting_list.rs            | 271 ++++++++
 crates/paimon/src/btree/query.rs                   | 119 ++--
 crates/paimon/src/btree/reader.rs                  |  36 +-
 crates/paimon/src/btree/tests.rs                   | 336 ++++++++++
 crates/paimon/src/btree/writer.rs                  |  35 +-
 crates/paimon/src/table/global_index_scanner.rs    |   4 +
 .../src/table/global_index_scanner/all_match.rs    | 103 +++
 .../paimon/src/table/global_index_scanner/entry.rs |   1 +
 .../src/table/global_index_scanner/evaluator.rs    | 142 +++-
 .../src/table/global_index_scanner/reader.rs       |  12 +
 .../src/table/global_index_scanner/row_ranges.rs   |  39 +-
 .../paimon/src/table/global_index_scanner/tests.rs | 733 +++++++++++++++++++++
 .../sorted_global_index_build_builder/tests.rs     | 248 +++++++
 .../sorted_global_index_build_builder/writer.rs    |   7 +-
 .../src/table/sorted_global_index_options.rs       |  32 +
 crates/paimon/testdata/btree/GenerateBTreeV2.java  |  68 ++
 crates/paimon/testdata/btree/README.md             |  44 ++
 crates/paimon/testdata/btree/btree_v2_java_lz4.bin | Bin 0 -> 419 bytes
 .../paimon/testdata/btree/btree_v2_java_none.bin   | Bin 0 -> 8562 bytes
 docs/src/sql.md                                    |  20 +-
 24 files changed, 2274 insertions(+), 142 deletions(-)

diff --git a/crates/integrations/datafusion/tests/procedures.rs 
b/crates/integrations/datafusion/tests/procedures.rs
index cb0779b8..73e4f143 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -361,6 +361,12 @@ async fn 
test_global_index_procedures_echo_raw_unsupported_type() {
 
 #[tokio::test]
 async fn test_create_global_index_builds_btree_and_filter_reads() {
+    for version in [1, 2] {
+        assert_btree_sql_version(version).await;
+    }
+}
+
+async fn assert_btree_sql_version(version: u32) {
     let (_tmp, sql_context) = 
setup_btree_global_index_table("btree_build").await;
     exec(
         &sql_context,
@@ -370,12 +376,12 @@ async fn 
test_create_global_index_builds_btree_and_filter_reads() {
 
     exec(
         &sql_context,
-        "CALL sys.create_global_index(\
+        &format!("CALL sys.create_global_index(\
             table => 'test_db.btree_build', \
             index_column => 'id', \
             index_type => 'btree', \
-            options => 
'btree-index.block-size=1kb,btree-index.compression=lz4,btree-index.compression-level=1'\
-        )",
+            options => 
'btree-index.file-version={version},btree-index.block-size=1kb,btree-index.compression=lz4,btree-index.compression-level=1'\
+        )"),
     )
     .await;
 
@@ -394,6 +400,25 @@ async fn 
test_create_global_index_builds_btree_and_filter_reads() {
     )
     .await;
     assert_eq!(rows, vec![(2, "bob".to_string())]);
+    for (condition, expected) in [
+        ("id >= 0 AND id = 2", vec![(2, "bob".to_string())]),
+        (
+            "id >= 0 AND id > 1 AND id < 3 AND id <= 4",
+            vec![(2, "bob".to_string())],
+        ),
+        ("id > 2 AND id < 2", vec![]),
+        (
+            "(id > 2 AND id < 2) OR id = 3",
+            vec![(3, "carol".to_string())],
+        ),
+    ] {
+        let rows = collect_id_name(
+            &sql_context,
+            &format!("SELECT id, name FROM paimon.test_db.btree_build WHERE 
{condition}"),
+        )
+        .await;
+        assert_eq!(rows, expected, "V{version}: {condition}");
+    }
 }
 
 #[tokio::test]
diff --git a/crates/paimon/src/arrow/format/row.rs 
b/crates/paimon/src/arrow/format/row.rs
index 9419507e..56278108 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -293,6 +293,9 @@ impl FormatFileReader for RowFormatReader {
             })?;
         index.validate_for_file(total_rows, index_start)?;
         validate_row_selection(total_rows, row_selection.as_deref())?;
+        // Public callers may supply unordered or overlapping ranges. Normalize
+        // once so every block can seek directly to its overlapping window.
+        let row_selection = row_selection.map(crate::table::merge_row_ranges);
 
         let schema = build_target_arrow_schema(read_fields)?;
         let row_type = read_fields.to_vec();
@@ -2294,11 +2297,8 @@ fn blocks_to_read(
         } else {
             total_rows
         };
-        let intersects = selection.is_none_or(|ranges| {
-            ranges
-                .iter()
-                .any(|r| (r.from() as usize) < end && (r.to() as usize) >= 
start)
-        });
+        let intersects =
+            selection.is_none_or(|ranges| !overlapping_ranges(ranges, start, 
end).is_empty());
         if intersects {
             result.push(block_idx);
         }
@@ -2315,7 +2315,7 @@ fn selected_local_indices(
         None => (0..block_end - block_start).collect(),
         Some(ranges) => {
             let mut result = Vec::new();
-            for range in ranges {
+            for range in overlapping_ranges(ranges, block_start, block_end) {
                 let start = (range.from() as usize).max(block_start);
                 let end = ((range.to() as usize) + 1).min(block_end);
                 if start < end {
@@ -2327,6 +2327,15 @@ fn selected_local_indices(
     }
 }
 
+/// Input is sorted, disjoint and nonnegative; `end` is exclusive.
+/// Never rescan a large prefix for each successive data block.
+fn overlapping_ranges(ranges: &[RowRange], start: usize, end: usize) -> 
&[RowRange] {
+    let first = ranges.partition_point(|range| (range.to() as usize) < start);
+    let suffix = &ranges[first..];
+    let count = suffix.partition_point(|range| (range.from() as usize) < end);
+    &suffix[..count]
+}
+
 fn i128_to_java_bigint_bytes(value: i128) -> Vec<u8> {
     let bytes = value.to_be_bytes();
     let pad = if value < 0 { 0xff } else { 0x00 };
@@ -2642,7 +2651,11 @@ mod tests {
                 &fields,
                 None,
                 Some(8),
-                Some(vec![RowRange::new(1, 2)]),
+                Some(vec![
+                    RowRange::new(2, 2),
+                    RowRange::new(1, 2),
+                    RowRange::new(1, 1),
+                ]),
             )
             .await
             .unwrap()
@@ -2979,6 +2992,106 @@ mod tests {
         assert_eq!(ids, vec![2, 3]);
     }
 
+    #[test]
+    #[ignore = "manual release benchmark; exact selection oracle, no timing 
assertions"]
+    fn benchmark_scalar_row_selection() {
+        use std::hint::black_box;
+        use std::time::Instant;
+        let rows = 1_200_000;
+        let block_rows = 1024;
+        let starts: Vec<_> = (0..rows).step_by(block_rows).map(|id| id as 
i64).collect();
+        let index =
+            RowBlockIndex::new(vec![1; starts.len()], vec![1; starts.len()], 
starts).unwrap();
+        for (name, run, stride) in [
+            ("90pct", 9, 10),
+            ("10pct", 1, 10),
+            ("contiguous", rows, rows),
+        ] {
+            let ranges: Vec<_> = (0..rows)
+                .step_by(stride)
+                .map(|id| RowRange::new(id as i64, (id + run - 1) as i64))
+                .collect();
+            let run_selection = || {
+                let blocks = blocks_to_read(&index, rows, Some(&ranges));
+                let mut selected = Vec::new();
+                for block in blocks {
+                    let start = index.block_row_start(block);
+                    let end = (start + block_rows).min(rows);
+                    selected.extend(
+                        selected_local_indices(start, end, Some(&ranges))
+                            .into_iter()
+                            .map(|id| start + id),
+                    );
+                }
+                selected
+            };
+            let expected: Vec<_> = ranges
+                .iter()
+                .flat_map(|r| r.from() as usize..=r.to() as usize)
+                .collect();
+            for _ in 0..3 {
+                assert_eq!(run_selection(), expected);
+            }
+            let mut samples = Vec::new();
+            for _ in 0..7 {
+                let start = Instant::now();
+                let selected = run_selection();
+                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
+                assert_eq!(selected, expected);
+                black_box(selected);
+                samples.push(elapsed);
+            }
+            samples.sort_by(f64::total_cmp);
+            println!(
+                "ROW_SELECTION_BENCH case={name} ranges={} blocks={} 
median_ms={:.3}",
+                ranges.len(),
+                index.block_count(),
+                samples[3]
+            );
+        }
+    }
+
+    #[test]
+    fn row_selection_windows_match_independent_row_oracle() {
+        let index = RowBlockIndex::new(vec![1; 4], vec![1; 4], vec![0, 10, 20, 
30]).unwrap();
+        for selection in [
+            None,
+            Some(vec![]),
+            Some(vec![RowRange::new(9, 11), RowRange::new(29, 39)]),
+            Some(vec![RowRange::new(0, 0), RowRange::new(39, 39)]),
+            Some(vec![RowRange::new(0, 39)]),
+        ] {
+            let expected: Vec<_> = (0..40)
+                .filter(|row| {
+                    selection.as_ref().is_none_or(|ranges| {
+                        ranges
+                            .iter()
+                            .any(|range| range.from() <= *row as i64 && *row 
as i64 <= range.to())
+                    })
+                })
+                .collect();
+            let blocks = blocks_to_read(&index, 40, selection.as_deref());
+            let mut actual = Vec::new();
+            for block in blocks {
+                actual.extend(
+                    selected_local_indices(block * 10, (block + 1) * 10, 
selection.as_deref())
+                        .into_iter()
+                        .map(|row| block * 10 + row),
+                );
+            }
+            assert_eq!(actual, expected);
+        }
+        let ranges: Vec<_> = (0..1_200_000)
+            .step_by(10)
+            .map(|row| RowRange::new(row, row))
+            .collect();
+        assert_eq!(
+            overlapping_ranges(&ranges, 1_199_989, 1_200_000),
+            &[RowRange::new(1_199_990, 1_199_990)]
+        );
+        assert!(overlapping_ranges(&ranges, 1_199_991, 1_200_000).is_empty());
+    }
+
     #[tokio::test]
     async fn row_reader_prefetches_blocks_with_bounded_parallelism() {
         let fields = vec![
diff --git a/crates/paimon/src/btree/footer.rs 
b/crates/paimon/src/btree/footer.rs
index 027802a7..7e625011 100644
--- a/crates/paimon/src/btree/footer.rs
+++ b/crates/paimon/src/btree/footer.rs
@@ -140,6 +140,13 @@ impl BTreeFileFooter {
             ));
         }
 
+        if !matches!(version, 1 | 2) {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidData,
+                format!("Unsupported BTree index version: {version}"),
+            ));
+        }
+
         let mut pos = 0;
 
         // bloom filter handle
diff --git a/crates/paimon/src/btree/mod.rs b/crates/paimon/src/btree/mod.rs
index 6cc4ea01..5b7fc6fa 100644
--- a/crates/paimon/src/btree/mod.rs
+++ b/crates/paimon/src/btree/mod.rs
@@ -41,6 +41,7 @@ mod bloom_filter;
 mod footer;
 pub(crate) mod key_serde;
 mod meta;
+mod posting_list;
 pub(crate) mod query;
 mod reader;
 mod sst_file;
diff --git a/crates/paimon/src/btree/posting_list.rs 
b/crates/paimon/src/btree/posting_list.rs
new file mode 100644
index 00000000..b2eb40e7
--- /dev/null
+++ b/crates/paimon/src/btree/posting_list.rs
@@ -0,0 +1,271 @@
+// 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.
+
+//! Java-compatible BTree V1 lists and adaptive V2 postings.
+
+use super::var_len::{encode_var_int, encode_var_long};
+use roaring::{RoaringBitmap, RoaringTreemap};
+use std::io::{self, Cursor, Read};
+
+const SINGLE: u8 = 0;
+const DELTA: u8 = 1;
+const ROARING: u8 = 2;
+
+fn invalid(message: &str) -> io::Error {
+    io::Error::new(io::ErrorKind::InvalidData, message)
+}
+
+// Reject overlong/overflowing encodings instead of allowing high bits to wrap.
+fn read_non_negative(input: &mut impl Read, bits: u32) -> io::Result<u64> {
+    let mut value = 0u64;
+    for shift in (0..bits).step_by(7) {
+        let mut byte = [0];
+        input.read_exact(&mut byte)?;
+        let payload = u64::from(byte[0] & 0x7f);
+        if payload >= (1u64 << (bits - shift).min(7)) {
+            return Err(invalid("BTree posting integer overflow"));
+        }
+        value |= payload << shift;
+        if byte[0] & 0x80 == 0 {
+            return Ok(value);
+        }
+    }
+    Err(invalid("Malformed BTree posting integer"))
+}
+
+pub(super) fn add_to(data: &[u8], version: u32, target: &mut RoaringTreemap) 
-> io::Result<()> {
+    let mut input = Cursor::new(data);
+    if version == 1 {
+        let count = read_non_negative(&mut input, 31)?;
+        for _ in 0..count {
+            target.insert(read_non_negative(&mut input, 63)?);
+        }
+    } else {
+        let mut tag = [0];
+        input.read_exact(&mut tag)?;
+        match tag[0] {
+            SINGLE => {
+                target.insert(read_non_negative(&mut input, 63)?);
+            }
+            DELTA => {
+                let count = read_non_negative(&mut input, 31)?;
+                if count < 2 {
+                    return Err(invalid("Invalid BTree delta posting count"));
+                }
+                let mut row = read_non_negative(&mut input, 63)?;
+                target.insert(row);
+                for _ in 1..count {
+                    let delta = read_non_negative(&mut input, 63)?;
+                    if delta == 0 {
+                        return Err(invalid("BTree row ID delta must be 
positive"));
+                    }
+                    row = row
+                        .checked_add(delta)
+                        .filter(|id| *id <= i64::MAX as u64)
+                        .ok_or_else(|| invalid("BTree delta row ID 
overflow"))?;
+                    target.insert(row);
+                }
+            }
+            ROARING => {
+                // The generic treemap decoder overwrites duplicate high keys.
+                // Validate the portable outer map so corrupt input cannot 
lose rows silently.
+                let mut count = [0; 8];
+                input.read_exact(&mut count)?;
+                let count = u64::from_le_bytes(count);
+                if count == 0 {
+                    return Err(invalid("Empty BTree Roaring posting"));
+                }
+                let mut bitmaps = Vec::new();
+                let mut previous = None;
+                for _ in 0..count {
+                    let mut high = [0; 4];
+                    input.read_exact(&mut high)?;
+                    let high = u32::from_le_bytes(high);
+                    if high > i32::MAX as u32 || previous.is_some_and(|last| 
high <= last) {
+                        return Err(invalid("Invalid BTree Roaring high keys"));
+                    }
+                    let bitmap = RoaringBitmap::deserialize_from(&mut input)?;
+                    if bitmap.is_empty() {
+                        return Err(invalid("Empty BTree Roaring partition"));
+                    }
+                    bitmaps.push((high, bitmap));
+                    previous = Some(high);
+                }
+                *target |= RoaringTreemap::from_bitmaps(bitmaps);
+            }
+            _ => return Err(invalid("Unknown BTree posting encoding")),
+        }
+    }
+    if input.position() != data.len() as u64 {
+        return Err(invalid("Trailing bytes in BTree posting"));
+    }
+    Ok(())
+}
+
+fn var_len(value: u64) -> usize {
+    ((64 - value.leading_zeros()).max(1) as usize).div_ceil(7)
+}
+
+fn container_lower_bound(count: u64, runs: u64) -> u64 {
+    4 + (2 * count).min(8192).min(2 + 4 * runs)
+}
+
+pub(super) fn serialize(rows: &[i64], version: u32) -> io::Result<Vec<u8>> {
+    let count = i32::try_from(rows.len()).map_err(|_| invalid("Too many BTree 
row IDs"))?;
+    if rows.is_empty() || rows.iter().any(|id| *id < 0) {
+        return Err(invalid("BTree posting requires non-negative row IDs"));
+    }
+    if version == 1 {
+        let mut result = Vec::new();
+        encode_var_int(&mut result, count)?;
+        for &row in rows {
+            encode_var_long(&mut result, row)?;
+        }
+        return Ok(result);
+    }
+    if rows.len() == 1 {
+        let mut result = vec![SINGLE];
+        encode_var_long(&mut result, rows[0])?;
+        return Ok(result);
+    }
+
+    let mut delta_size = 1 + var_len(rows.len() as u64) + var_len(rows[0] as 
u64);
+    // Tag, high-key count, first high key, and nested bitmap cookie. As in 
Java,
+    // a lower bound avoids building a bitmap when a delta list must be 
smaller.
+    let mut roaring_min = 1 + 8 + 4 + 4;
+    let mut count_in_container = 1;
+    let mut runs = 1;
+    for pair in rows.windows(2) {
+        let (previous, row) = (pair[0], pair[1]);
+        if row <= previous {
+            return Err(invalid("BTree V2 row IDs must be strictly 
increasing"));
+        }
+        delta_size += var_len((row - previous) as u64);
+        if row >> 16 != previous >> 16 {
+            roaring_min += container_lower_bound(count_in_container, runs);
+            if row >> 32 != previous >> 32 {
+                roaring_min += 8;
+            }
+            count_in_container = 1;
+            runs = 1;
+        } else {
+            count_in_container += 1;
+            if row != previous + 1 {
+                runs += 1;
+            }
+        }
+    }
+    roaring_min += container_lower_bound(count_in_container, runs);
+    if roaring_min < delta_size as u64 {
+        let mut bitmap = RoaringTreemap::new();
+        let mut start = rows[0] as u64;
+        let mut end = start;
+        for &row in &rows[1..] {
+            let row = row as u64;
+            if row != end + 1 {
+                bitmap.insert_range(start..=end);
+                start = row;
+            }
+            end = row;
+        }
+        bitmap.insert_range(start..=end);
+        bitmap.optimize();
+        if 1 + bitmap.serialized_size() < delta_size {
+            let mut result = Vec::with_capacity(1 + bitmap.serialized_size());
+            result.push(ROARING);
+            bitmap.serialize_into(&mut result)?;
+            return Ok(result);
+        }
+    }
+    let mut result = Vec::with_capacity(delta_size);
+    result.push(DELTA);
+    encode_var_int(&mut result, count)?;
+    encode_var_long(&mut result, rows[0])?;
+    for pair in rows.windows(2) {
+        encode_var_long(&mut result, pair[1] - pair[0])?;
+    }
+    Ok(result)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn encodings_and_boundaries() {
+        for (rows, tag) in [
+            (vec![i64::MAX], SINGLE),
+            (vec![0, 128, (1i64 << 32) + 5, i64::MAX], DELTA),
+            ((60000..140000).collect(), ROARING),
+            (((1i64 << 32) - 10..(1i64 << 32) + 10000).collect(), ROARING),
+        ] {
+            let expected: RoaringTreemap = rows.iter().map(|id| *id as 
u64).collect();
+            for version in [1, 2] {
+                let bytes = serialize(&rows, version).unwrap();
+                if version == 2 {
+                    assert_eq!(bytes[0], tag);
+                }
+                let mut actual = RoaringTreemap::new();
+                add_to(&bytes, version, &mut actual).unwrap();
+                assert_eq!(actual, expected);
+            }
+        }
+    }
+
+    #[test]
+    fn rejects_corrupt_postings() {
+        for bytes in [
+            vec![],
+            vec![3],
+            vec![SINGLE],
+            vec![SINGLE, 1, 2],
+            vec![DELTA, 0],
+            vec![DELTA, 1, 0],
+            vec![DELTA, 2, 1, 0],
+            vec![DELTA, 3, 0, 1],
+            vec![ROARING, 0, 0, 0, 0, 0, 0, 0, 0],
+            vec![SINGLE, 255, 255, 255, 255, 255, 255, 255, 255, 255, 1],
+        ] {
+            assert!(
+                add_to(&bytes, 2, &mut RoaringTreemap::new()).is_err(),
+                "{bytes:?}"
+            );
+        }
+        let mut overflow = vec![DELTA, 2];
+        encode_var_long(&mut overflow, i64::MAX).unwrap();
+        overflow.push(1);
+        assert!(add_to(&overflow, 2, &mut RoaringTreemap::new()).is_err());
+        for rows in [vec![], vec![-1], vec![1, 1], vec![2, 1]] {
+            assert!(serialize(&rows, 2).is_err());
+        }
+    }
+
+    #[test]
+    fn rejects_duplicate_unordered_and_unsigned_roaring_partitions() {
+        for keys in [[0, 0], [1, 0], [0, 1 << 31]] {
+            let mut bytes = vec![ROARING];
+            bytes.extend_from_slice(&2u64.to_le_bytes());
+            for (high, row) in keys.into_iter().zip([1, 2]) {
+                bytes.extend_from_slice(&u32::to_le_bytes(high));
+                RoaringBitmap::from_iter([row])
+                    .serialize_into(&mut bytes)
+                    .unwrap();
+            }
+            assert!(add_to(&bytes, 2, &mut RoaringTreemap::new()).is_err());
+        }
+    }
+}
diff --git a/crates/paimon/src/btree/query.rs b/crates/paimon/src/btree/query.rs
index 2573dc16..cb7688c0 100644
--- a/crates/paimon/src/btree/query.rs
+++ b/crates/paimon/src/btree/query.rs
@@ -189,80 +189,89 @@ pub(crate) struct BetweenInfo<'a> {
     pub data_type: &'a DataType,
 }
 
+impl BetweenInfo<'_> {
+    pub(crate) fn is_empty(&self) -> bool {
+        let cmp = crate::btree::make_key_comparator(self.data_type);
+        match cmp(
+            &serialize_datum(self.from, self.data_type),
+            &serialize_datum(self.to, self.data_type),
+        ) {
+            Ordering::Greater => true,
+            Ordering::Equal => !self.from_inclusive || !self.to_inclusive,
+            Ordering::Less => false,
+        }
+    }
+}
+
 pub(crate) type ExtractBetweenResult<'a> = (
     Option<BetweenInfo<'a>>,
     Vec<(PredicateOperator, &'a [Datum], &'a DataType)>,
 );
 
-/// Try to extract a between pattern (lower + upper bound) from predicates.
+/// Tighten all lower/upper bounds on one field into a single range query.
 /// Returns (between_info, remaining_predicates).
 ///
-/// Recognizes two shapes:
-/// 1. A native `Between` leaf with two literals — preferred and emitted by the
-///    DataFusion translator since Stage 3.
-/// 2. A `GtEq` / `Gt` paired with a `LtEq` / `Lt` on the same column —
-///    legacy shape, kept for direct `PredicateBuilder` users that still build
-///    the conjunction explicitly.
+/// Native `Between` leaves and explicit comparisons participate together.
+/// Float comparisons intentionally follow the residual filter's bit-preserving
+/// total order, not bitmap dictionary ordering (which canonicalizes all NaNs).
+/// Bitmap floating ranges conservatively return all non-null candidates.
 pub(crate) fn extract_between<'a>(
     predicates: &[(PredicateOperator, &'a [Datum], &'a DataType)],
 ) -> ExtractBetweenResult<'a> {
-    // Shape 1: native Between leaf — single tuple is enough.
-    for (i, (op, literals, dt)) in predicates.iter().enumerate() {
-        if matches!(op, PredicateOperator::Between) && literals.len() == 2 {
-            let between = BetweenInfo {
-                from: &literals[0],
-                to: &literals[1],
-                from_inclusive: true,
-                to_inclusive: true,
-                data_type: dt,
-            };
-            let remaining: Vec<_> = predicates
-                .iter()
-                .enumerate()
-                .filter(|(j, _)| *j != i)
-                .map(|(_, p)| *p)
-                .collect();
-            return (Some(between), remaining);
-        }
-    }
-
-    if predicates.len() < 2 {
+    let Some((_, _, data_type)) = predicates.first() else {
+        return (None, Vec::new());
+    };
+    if predicates.len() == 1 && predicates[0].0 != PredicateOperator::Between {
         return (None, predicates.to_vec());
     }
-
-    let mut lower: Option<(usize, bool)> = None; // (index, inclusive)
-    let mut upper: Option<(usize, bool)> = None;
-
-    for (i, (op, literals, _)) in predicates.iter().enumerate() {
-        if literals.len() != 1 {
+    let cmp = crate::btree::make_key_comparator(data_type);
+    let mut lower: Option<(&Datum, Vec<u8>, bool)> = None;
+    let mut upper: Option<(&Datum, Vec<u8>, bool)> = None;
+    let mut remaining = Vec::new();
+    for &(op, literals, ty) in predicates {
+        if ty != *data_type {
+            remaining.push((op, literals, ty));
             continue;
         }
-        match op {
-            PredicateOperator::GtEq if lower.is_none() => lower = Some((i, 
true)),
-            PredicateOperator::Gt if lower.is_none() => lower = Some((i, 
false)),
-            PredicateOperator::LtEq if upper.is_none() => upper = Some((i, 
true)),
-            PredicateOperator::Lt if upper.is_none() => upper = Some((i, 
false)),
-            _ => {}
+        let (from, to) = match (op, literals) {
+            (PredicateOperator::GtEq, [value]) => (Some((value, true)), None),
+            (PredicateOperator::Gt, [value]) => (Some((value, false)), None),
+            (PredicateOperator::LtEq, [value]) => (None, Some((value, true))),
+            (PredicateOperator::Lt, [value]) => (None, Some((value, false))),
+            (PredicateOperator::Between, [from, to]) => (Some((from, true)), 
Some((to, true))),
+            _ => {
+                remaining.push((op, literals, ty));
+                continue;
+            }
+        };
+        for (candidate, bound, tighter) in [
+            (from, &mut lower, Ordering::Greater),
+            (to, &mut upper, Ordering::Less),
+        ] {
+            if let Some((value, inclusive)) = candidate {
+                let key = serialize_datum(value, data_type);
+                match bound {
+                    Some((_, existing, current_inclusive)) => match cmp(&key, 
existing) {
+                        Ordering::Equal => *current_inclusive &= inclusive,
+                        order if order == tighter => *bound = Some((value, 
key, inclusive)),
+                        _ => {}
+                    },
+                    None => *bound = Some((value, key, inclusive)),
+                }
+            }
         }
     }
-
     match (lower, upper) {
-        (Some((li, from_inclusive)), Some((ui, to_inclusive))) => {
-            let between = BetweenInfo {
-                from: &predicates[li].1[0],
-                to: &predicates[ui].1[0],
+        (Some((from, _, from_inclusive)), Some((to, _, to_inclusive))) => (
+            Some(BetweenInfo {
+                from,
+                to,
                 from_inclusive,
                 to_inclusive,
-                data_type: predicates[li].2,
-            };
-            let remaining: Vec<_> = predicates
-                .iter()
-                .enumerate()
-                .filter(|(i, _)| *i != li && *i != ui)
-                .map(|(_, p)| *p)
-                .collect();
-            (Some(between), remaining)
-        }
+                data_type,
+            }),
+            remaining,
+        ),
         _ => (None, predicates.to_vec()),
     }
 }
diff --git a/crates/paimon/src/btree/reader.rs 
b/crates/paimon/src/btree/reader.rs
index 7f8f1ba1..1debf239 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -27,13 +27,13 @@ use crate::btree::block::{BlockHandle, BlockReader};
 use crate::btree::bloom_filter::BloomFilter;
 use crate::btree::footer::{BTreeFileFooter, BloomFilterHandle, 
BTREE_FOOTER_ENCODED_LENGTH};
 use crate::btree::meta::BTreeIndexMeta;
+use crate::btree::posting_list;
 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 std::io;
 use tokio::sync::OnceCell;
 
 struct LazyBloomFilter {
@@ -50,6 +50,7 @@ pub struct BTreeIndexReader<F: Fn(&[u8], &[u8]) -> Ordering> {
     max_key: Option<Vec<u8>>,
     key_comparator: F,
     bloom_filter: Option<LazyBloomFilter>,
+    file_version: u32,
 }
 
 impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
@@ -105,6 +106,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
             max_key: meta.last_key.clone(),
             key_comparator,
             bloom_filter,
+            file_version: footer.version,
         })
     }
 
@@ -149,7 +151,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
                 let (key, value, next_offset) = block.read_entry_at(offset);
                 offset = next_offset;
                 if predicate(key) {
-                    insert_row_ids_into(value, &mut result)?;
+                    posting_list::add_to(value, self.file_version, &mut 
result)?;
                 }
             }
         }
@@ -169,6 +171,12 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
         let cmp = &self.key_comparator;
         let mut result = RoaringTreemap::new();
 
+        match cmp(from, to) {
+            Ordering::Greater => return Ok(result),
+            Ordering::Equal if !from_inclusive || !to_inclusive => return 
Ok(result),
+            _ => {}
+        }
+
         // Seek in index block to find the first data block that may contain 
`from`
         let index_block = self.sst_reader.index_block();
         let (_, mut index_iter) = index_block.seek_and_iter(from, cmp);
@@ -246,7 +254,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
                 return Ok(true);
             }
 
-            insert_row_ids_into(value, result)?;
+            posting_list::add_to(value, self.file_version, result)?;
         }
         Ok(false)
     }
@@ -334,7 +342,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
         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)?;
+            posting_list::add_to(value, self.file_version, &mut result)?;
         }
         Ok(result)
     }
@@ -457,7 +465,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexReader<F> {
             for key in block_keys {
                 let (found, mut entry_iter) = block.seek_and_iter(key, cmp);
                 if let (true, Some((_entry_key, value))) = (found, 
entry_iter.next()) {
-                    insert_row_ids_into(value, &mut result)?;
+                    posting_list::add_to(value, self.file_version, &mut 
result)?;
                 }
             }
         }
@@ -511,19 +519,3 @@ fn verify_null_bitmap_crc(bitmap_bytes: &[u8], crc_bytes: 
&[u8]) -> io::Result<(
     }
     Ok(())
 }
-
-/// Deserialize row ids from value bytes and insert directly into bitmap.
-fn insert_row_ids_into(data: &[u8], bitmap: &mut RoaringTreemap) -> 
io::Result<()> {
-    let mut cursor = Cursor::new(data);
-    let count = decode_var_int(&mut cursor)?;
-    if count < 0 {
-        return Err(io::Error::new(
-            io::ErrorKind::InvalidData,
-            format!("Invalid row id count: {count}"),
-        ));
-    }
-    for _ in 0..count {
-        bitmap.insert(decode_var_long(&mut cursor)? as u64);
-    }
-    Ok(())
-}
diff --git a/crates/paimon/src/btree/tests.rs b/crates/paimon/src/btree/tests.rs
index 89ac793b..9e9d6c92 100644
--- a/crates/paimon/src/btree/tests.rs
+++ b/crates/paimon/src/btree/tests.rs
@@ -94,10 +94,346 @@ async fn test_write_read_roundtrip() {
     assert_eq!(all.len(), 100);
 }
 
+#[tokio::test]
+async fn test_tightest_bounds_across_key_types_and_versions() {
+    use crate::btree::query::extract_between;
+    use crate::btree::{make_key_comparator, serialize_datum};
+    use crate::spec::*;
+    let cases = vec![
+        (
+            DataType::TinyInt(TinyIntType::new()),
+            (0..6).map(Datum::TinyInt).collect::<Vec<_>>(),
+        ),
+        (
+            DataType::SmallInt(SmallIntType::new()),
+            (0..6).map(Datum::SmallInt).collect(),
+        ),
+        (
+            DataType::Int(IntType::new()),
+            (0..6).map(Datum::Int).collect(),
+        ),
+        (
+            DataType::BigInt(BigIntType::new()),
+            (0..6).map(Datum::Long).collect(),
+        ),
+        (
+            DataType::Float(FloatType::new()),
+            (0..6).map(|v| Datum::Float(v as f32)).collect(),
+        ),
+        (
+            DataType::Double(DoubleType::new()),
+            (0..6).map(|v| Datum::Double(v as f64)).collect(),
+        ),
+        (
+            DataType::VarChar(VarCharType::string_type()),
+            (0..6).map(|v| Datum::String(v.to_string())).collect(),
+        ),
+        (
+            DataType::Date(DateType::new()),
+            (0..6).map(Datum::Date).collect(),
+        ),
+        (
+            DataType::Time(TimeType::new(3).unwrap()),
+            (0..6).map(Datum::Time).collect(),
+        ),
+        (
+            DataType::Timestamp(TimestampType::new(9).unwrap()),
+            (0..6)
+                .map(|v| Datum::Timestamp {
+                    millis: 0,
+                    nanos: v,
+                })
+                .collect(),
+        ),
+        (
+            DataType::Decimal(DecimalType::new(10, 0).unwrap()),
+            (0..6)
+                .map(|v| Datum::Decimal {
+                    unscaled: v,
+                    precision: 10,
+                    scale: 0,
+                })
+                .collect(),
+        ),
+        (
+            DataType::Decimal(DecimalType::new(30, 0).unwrap()),
+            (0..6)
+                .map(|v| Datum::Decimal {
+                    unscaled: v,
+                    precision: 30,
+                    scale: 0,
+                })
+                .collect(),
+        ),
+    ];
+    for (ty, values) in cases {
+        for version in [1, 2] {
+            let buf = VecFileWrite::new();
+            let mut writer =
+                BTreeIndexWriter::new(Box::new(buf.clone()), 64, 
BlockCompressionType::None)
+                    .with_file_version(version)
+                    .unwrap();
+            writer.write(None, 6).await.unwrap();
+            for (row, value) in values.iter().enumerate() {
+                writer
+                    .write(Some(&serialize_datum(value, &ty)), row as i64)
+                    .await
+                    .unwrap();
+            }
+            let result = writer.finish().await.unwrap();
+            let reader = write_and_open(&buf, &result, 
make_key_comparator(&ty)).await;
+            for reverse in [false, true] {
+                // A native Between plus multiple pairs, with strictness ties.
+                let native = vec![values[0].clone(), values[5].clone()];
+                let mut predicates = vec![
+                    (PredicateOperator::Between, native.as_slice(), &ty),
+                    (PredicateOperator::GtEq, &values[1..2], &ty),
+                    (PredicateOperator::LtEq, &values[4..5], &ty),
+                    (PredicateOperator::Gt, &values[1..2], &ty),
+                    (PredicateOperator::Lt, &values[4..5], &ty),
+                    (PredicateOperator::IsNotNull, &values[0..0], &ty),
+                ];
+                if reverse {
+                    predicates.reverse();
+                }
+                let (range, remaining) = extract_between(&predicates);
+                assert_eq!(remaining.len(), 1);
+                assert_eq!(remaining[0].0, PredicateOperator::IsNotNull);
+                let range = range.unwrap();
+                assert!(!range.is_empty());
+                let actual = reader
+                    .range_query(
+                        &serialize_datum(range.from, &ty),
+                        &serialize_datum(range.to, &ty),
+                        range.from_inclusive,
+                        range.to_inclusive,
+                    )
+                    .await
+                    .unwrap();
+                assert_eq!(
+                    actual.iter().collect::<Vec<_>>(),
+                    vec![2, 3],
+                    "{ty:?}, V{version}"
+                );
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_empty_range_does_not_read_data_blocks() {
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 64, 
BlockCompressionType::None);
+    for row in 0..100 {
+        writer.write(Some(&int_key(row)), row as i64).await.unwrap();
+    }
+    let result = writer.finish().await.unwrap();
+    let data = Bytes::from(buf.to_vec());
+    let reads = Arc::new(Mutex::new(Vec::new()));
+    let reader = BTreeIndexReader::open(
+        Box::new(RecordingFileRead {
+            data: data.clone(),
+            ranges: reads.clone(),
+        }),
+        data.len() as u64,
+        &result.meta,
+        int_cmp,
+    )
+    .await
+    .unwrap();
+    reads.lock().unwrap().clear();
+    for (from, to, lower, upper) in [
+        (50, 40, true, true),
+        (50, 50, false, true),
+        (50, 50, true, false),
+        (50, 50, false, false),
+    ] {
+        assert!(reader
+            .range_query(&int_key(from), &int_key(to), lower, upper)
+            .await
+            .unwrap()
+            .is_empty());
+    }
+    assert!(reads.lock().unwrap().is_empty());
+}
+
 fn read_footer(data: &[u8]) -> BTreeFileFooter {
     BTreeFileFooter::read_footer(&data[data.len() - 
BTREE_FOOTER_ENCODED_LENGTH..]).unwrap()
 }
 
+#[tokio::test]
+async fn test_java_v2_posting_encodings() {
+    // Existing low-level tests use arbitrary big-endian keys. Java's INT key
+    // serializer uses little-endian bytes, so use the production codec here.
+    let data_type = DataType::Int(crate::spec::IntType::new());
+    let int_key = |v| crate::btree::serialize_datum(&Datum::Int(v), 
&data_type);
+    for fixture in [
+        
include_bytes!("../../testdata/btree/btree_v2_java_none.bin").as_slice(),
+        
include_bytes!("../../testdata/btree/btree_v2_java_lz4.bin").as_slice(),
+    ] {
+        let reader = BTreeIndexReader::open(
+            Box::new(BytesFileRead(Bytes::copy_from_slice(fixture))),
+            fixture.len() as u64,
+            &BTreeIndexMeta::new(Some(int_key(0)), Some(int_key(5)), true),
+            crate::btree::make_key_comparator(&data_type),
+        )
+        .await
+        .unwrap();
+        let expected = [
+            [7].into_iter().collect::<roaring::RoaringTreemap>(),
+            [2, 130, 65538, (1u64 << 32) + 5].into_iter().collect(),
+            (60000..140000).collect(),
+            ((1u64 << 32) - 10..(1u64 << 32) + 10000).collect(),
+            [i64::MAX as u64].into_iter().collect(),
+            (0..4096).map(|id| (1u64 << 40) + id * 128).collect(),
+        ];
+        for (key, rows) in expected.iter().enumerate() {
+            assert_eq!(
+                reader.query_equal(&int_key(key as i32)).await.unwrap(),
+                *rows
+            );
+        }
+        assert_eq!(
+            reader.null_bitmap().iter().collect::<Vec<_>>(),
+            vec![4, (1u64 << 32) + 20000]
+        );
+        assert_eq!(
+            reader.query_in(&[&int_key(1), &int_key(3)]).await.unwrap(),
+            &expected[1] | &expected[3]
+        );
+        assert_eq!(
+            reader
+                .range_query(&int_key(1), &int_key(3), false, false)
+                .await
+                .unwrap(),
+            expected[2]
+        );
+        assert_eq!(
+            reader.scan_entries(|key| key == int_key(2)).await.unwrap(),
+            expected[2]
+        );
+    }
+}
+
+#[tokio::test]
+async fn test_v2_writer_matches_java_fixture() {
+    let data_type = DataType::Int(crate::spec::IntType::new());
+    let int_key = |v| crate::btree::serialize_datum(&Datum::Int(v), 
&data_type);
+    let buf = VecFileWrite::new();
+    let mut writer = BTreeIndexWriter::with_comparator(
+        Box::new(buf.clone()),
+        64,
+        BlockCompressionType::None,
+        crate::btree::make_key_comparator(&data_type),
+    )
+    .with_file_version(2)
+    .unwrap();
+    writer.write(Some(&int_key(0)), 7).await.unwrap();
+    for row in [2, 130, 65538, (1i64 << 32) + 5] {
+        writer.write(Some(&int_key(1)), row).await.unwrap();
+    }
+    for row in 60000..140000 {
+        writer.write(Some(&int_key(2)), row).await.unwrap();
+    }
+    for row in (1i64 << 32) - 10..(1i64 << 32) + 10000 {
+        writer.write(Some(&int_key(3)), row).await.unwrap();
+    }
+    writer.write(Some(&int_key(4)), i64::MAX).await.unwrap();
+    for id in 0..4096 {
+        writer
+            .write(Some(&int_key(5)), (1i64 << 40) + id * 128)
+            .await
+            .unwrap();
+    }
+    writer.write(None, 4).await.unwrap();
+    writer.write(None, (1i64 << 32) + 20000).await.unwrap();
+    writer.finish().await.unwrap();
+    assert_eq!(
+        buf.to_vec(),
+        include_bytes!("../../testdata/btree/btree_v2_java_none.bin")
+    );
+    assert!(
+        include_bytes!("../../testdata/btree/btree_v2_java_lz4.bin").len()
+            < 
include_bytes!("../../testdata/btree/btree_v2_java_none.bin").len()
+    );
+}
+
+#[test]
+fn test_unsupported_version_is_rejected() {
+    for version in [0u32, 3, u32::MAX] {
+        let mut bytes = 
include_bytes!("../../testdata/btree/btree_v2_java_none.bin").to_vec();
+        let pos = bytes.len() - 8;
+        bytes[pos..pos + 4].copy_from_slice(&version.to_le_bytes());
+        assert!(BTreeFileFooter::read_footer(&bytes[bytes.len() - 
52..]).is_err());
+        assert!(BTreeIndexWriter::new(
+            Box::new(VecFileWrite::new()),
+            64,
+            BlockCompressionType::None
+        )
+        .with_file_version(version)
+        .is_err());
+    }
+}
+
+#[tokio::test]
+#[ignore = "manual release benchmark; no wall-clock assertions"]
+async fn benchmark_btree_v1_v2_postings() {
+    use std::hint::black_box;
+    use std::time::Instant;
+    let count = 1_200_000;
+    let mut readers = Vec::new();
+    for version in [1, 2] {
+        let buf = VecFileWrite::new();
+        let mut writer =
+            BTreeIndexWriter::new(Box::new(buf.clone()), 64 * 1024, 
BlockCompressionType::None)
+                .with_file_version(version)
+                .unwrap();
+        for key in [0, 1] {
+            for id in 0..count {
+                if (id % 10 != 0) as i32 == key {
+                    writer.write(Some(&int_key(key)), id).await.unwrap();
+                }
+            }
+        }
+        writer.write(Some(&int_key(2)), count).await.unwrap();
+        let result = writer.finish().await.unwrap();
+        let reader = write_and_open(&buf, &result, int_cmp).await;
+        println!(
+            "BTREE_BENCH version={version} file_bytes={}",
+            buf.to_vec().len()
+        );
+        readers.push(reader);
+    }
+    for (key, expected) in [(0, count / 10), (1, count * 9 / 10), (2, 1)] {
+        for reader in &readers {
+            for _ in 0..3 {
+                assert_eq!(
+                    reader.query_equal(&int_key(key)).await.unwrap().len(),
+                    expected as u64
+                );
+            }
+        }
+        let mut times = [Vec::new(), Vec::new()];
+        for round in 0..7 {
+            for version in [round % 2, 1 - round % 2] {
+                let start = Instant::now();
+                let rows = 
readers[version].query_equal(&int_key(key)).await.unwrap();
+                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
+                assert_eq!(rows.len(), expected as u64);
+                black_box(rows);
+                times[version].push(elapsed);
+            }
+        }
+        for samples in &mut times {
+            samples.sort_by(f64::total_cmp);
+        }
+        println!(
+            "BTREE_BENCH key={key} rows={expected} v1_ms={:.3} v2_ms={:.3}",
+            times[0][3], times[1][3]
+        );
+    }
+}
+
 #[tokio::test]
 async fn test_bloom_filter_is_disabled_by_default() {
     let buf = VecFileWrite::new();
diff --git a/crates/paimon/src/btree/writer.rs 
b/crates/paimon/src/btree/writer.rs
index 6743174f..0ded1392 100644
--- a/crates/paimon/src/btree/writer.rs
+++ b/crates/paimon/src/btree/writer.rs
@@ -24,8 +24,8 @@
 use crate::btree::block::{BlockCompressionType, BlockHandle};
 use crate::btree::footer::BTreeFileFooter;
 use crate::btree::meta::BTreeIndexMeta;
+use crate::btree::posting_list;
 use crate::btree::sst_file::SstFileWriter;
-use crate::btree::var_len::{encode_var_int, encode_var_long};
 use crate::io::FileWrite;
 use roaring::RoaringTreemap;
 use std::cmp::Ordering;
@@ -46,6 +46,7 @@ pub struct BTreeIndexWriter<F: Fn(&[u8], &[u8]) -> Ordering> {
     null_bitmap: Option<RoaringTreemap>,
     row_count: u64,
     key_comparator: F,
+    file_version: u32,
 }
 
 /// Result of finishing a BTree index write.
@@ -84,6 +85,7 @@ impl BTreeIndexWriter<fn(&[u8], &[u8]) -> Ordering> {
             null_bitmap: None,
             row_count: 0,
             key_comparator: |a, b| a.cmp(b),
+            file_version: 1,
         }
     }
 }
@@ -139,13 +141,34 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexWriter<F> 
{
             null_bitmap: None,
             row_count: 0,
             key_comparator: cmp,
+            file_version: 1,
         }
     }
 
+    /// Select the on-disk version before writing any rows. V1 remains the 
default;
+    /// V2 requires readers that support adaptive postings.
+    pub fn with_file_version(mut self, version: u32) -> io::Result<Self> {
+        if !matches!(version, 1 | 2) || self.row_count != 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                "BTree file version must be 1 or 2 and set before writing",
+            ));
+        }
+        self.file_version = version;
+        Ok(self)
+    }
+
     /// Write a key and its associated row id.
     /// If key is None, the row id is added to the null bitmap.
     /// Keys must be written in sorted order; entries with the same key are 
combined.
+    /// In V2, row IDs for the same key must also be strictly increasing.
     pub async fn write(&mut self, key: Option<&[u8]>, row_id: i64) -> 
io::Result<()> {
+        if row_id < 0 {
+            return Err(io::Error::new(
+                io::ErrorKind::InvalidInput,
+                "BTree row ID must be non-negative",
+            ));
+        }
         self.row_count += 1;
 
         match key {
@@ -177,12 +200,7 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexWriter<F> {
             return Ok(());
         }
 
-        // Serialize row id list: var_len_int(count) + var_len_long(id) * count
-        let mut value_buf = Vec::with_capacity(self.current_row_ids.len() * 9 
+ 5);
-        encode_var_int(&mut value_buf, self.current_row_ids.len() as i32)?;
-        for &row_id in &self.current_row_ids {
-            encode_var_long(&mut value_buf, row_id)?;
-        }
+        let value_buf = posting_list::serialize(&self.current_row_ids, 
self.file_version)?;
         self.current_row_ids.clear();
 
         if let Some(ref key) = self.last_key {
@@ -237,8 +255,9 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering> BTreeIndexWriter<F> {
         let index_block_handle = self.sst_writer.write_index_block().await?;
 
         // Write footer
-        let footer =
+        let mut footer =
             BTreeFileFooter::new(bloom_filter_handle, index_block_handle, 
null_bitmap_handle);
+        footer.version = self.file_version;
         let footer_bytes = footer.write_footer();
         self.sst_writer.write_raw(&footer_bytes).await?;
 
diff --git a/crates/paimon/src/table/global_index_scanner.rs 
b/crates/paimon/src/table/global_index_scanner.rs
index c61f3825..5fbeffa6 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -20,6 +20,7 @@
 //!
 //! Reference: 
[org.apache.paimon.index.GlobalIndexScanner](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/index/GlobalIndexScanner.java)
 
+mod all_match;
 mod deletion_vectors;
 mod entry;
 mod evaluator;
@@ -62,6 +63,8 @@ const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
 struct QueryIoProbe {
     active: TestAtomicUsize,
     peak: TestAtomicUsize,
+    predicate_queries: TestAtomicUsize,
+    range_queries: TestAtomicUsize,
 }
 
 #[cfg(test)]
@@ -262,6 +265,7 @@ impl GlobalIndexScanner {
                 file_name: entry.index_file.file_name.clone(),
                 index_type: kind,
                 file_size: entry.index_file.file_size,
+                row_count: entry.index_file.row_count,
                 row_range_start: global_meta.row_range_start,
                 row_range_end: global_meta.row_range_end,
                 external_path: entry.index_file.external_path.clone(),
diff --git a/crates/paimon/src/table/global_index_scanner/all_match.rs 
b/crates/paimon/src/table/global_index_scanner/all_match.rs
new file mode 100644
index 00000000..57da267b
--- /dev/null
+++ b/crates/paimon/src/table/global_index_scanner/all_match.rs
@@ -0,0 +1,103 @@
+// 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.
+
+//! Prove complete scalar BTree domains match without opening index files.
+
+use super::entry::{sorted_entry_meta, GlobalIndexEntry, GlobalIndexFileKind};
+use crate::btree::{make_key_comparator, serialize_datum};
+use crate::spec::{DataType, Datum, PredicateOperator};
+use std::cmp::Ordering::{Equal, Greater, Less};
+use std::collections::HashMap;
+
+pub(super) fn all_matching_entries(
+    entries: &[&GlobalIndexEntry],
+    predicates: &[(PredicateOperator, &[Datum], &DataType)],
+) -> Vec<Vec<bool>> {
+    let mut groups: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
+    for (index, entry) in entries.iter().enumerate() {
+        if entry.index_type == GlobalIndexFileKind::BTree {
+            groups
+                .entry((entry.row_range_start, entry.row_range_end))
+                .or_default()
+                .push(index);
+        }
+    }
+    let comparisons: Vec<_> = predicates
+        .iter()
+        .map(|(op, literals, ty)| {
+            (
+                *op,
+                literals
+                    .iter()
+                    .map(|literal| serialize_datum(literal, ty))
+                    .collect::<Vec<_>>(),
+                make_key_comparator(ty),
+            )
+        })
+        .collect();
+    let mut result = vec![vec![false; entries.len()]; predicates.len()];
+    for ((start, end), files) in groups {
+        let Some(expected_count) = end
+            .checked_sub(start)
+            .and_then(|len| len.checked_add(1))
+            .filter(|len| *len > 0 && start >= 0)
+        else {
+            continue;
+        };
+        // Multiple files can partition keys within the SAME source row domain.
+        // A single file's min/max never proves that it contains every source 
row.
+        let count = files.iter().try_fold(0i64, |count, &index| {
+            let rows = entries[index].row_count;
+            (rows > 0).then(|| count.checked_add(rows)).flatten()
+        });
+        if count != Some(expected_count) {
+            continue;
+        }
+        for (predicate_index, (op, values, cmp)) in 
comparisons.iter().enumerate() {
+            let all_match = files.iter().all(|&index| {
+                let meta = sorted_entry_meta(entries[index]);
+                let (Some(first), Some(last)) = (&meta.first_key, 
&meta.last_key) else {
+                    return false;
+                };
+                !meta.has_nulls && {
+                    if cmp(first, last) == Greater {
+                        return false;
+                    }
+                    match (op, values.as_slice()) {
+                        (PredicateOperator::Eq, [value]) => {
+                            cmp(first, value) == Equal && cmp(last, value) == 
Equal
+                        }
+                        (PredicateOperator::Lt, [value]) => cmp(last, value) 
== Less,
+                        (PredicateOperator::LtEq, [value]) => cmp(last, value) 
!= Greater,
+                        (PredicateOperator::Gt, [value]) => cmp(first, value) 
== Greater,
+                        (PredicateOperator::GtEq, [value]) => cmp(first, 
value) != Less,
+                        (PredicateOperator::Between, [from, to]) => {
+                            cmp(first, from) != Less && cmp(last, to) != 
Greater
+                        }
+                        _ => false,
+                    }
+                }
+            });
+            if all_match {
+                for &index in &files {
+                    result[predicate_index][index] = true;
+                }
+            }
+        }
+    }
+    result
+}
diff --git a/crates/paimon/src/table/global_index_scanner/entry.rs 
b/crates/paimon/src/table/global_index_scanner/entry.rs
index 8fb3fc8e..d402f69b 100644
--- a/crates/paimon/src/table/global_index_scanner/entry.rs
+++ b/crates/paimon/src/table/global_index_scanner/entry.rs
@@ -30,6 +30,7 @@ pub(super) struct GlobalIndexEntry {
     pub(super) file_name: String,
     pub(super) index_type: GlobalIndexFileKind,
     pub(super) file_size: i64,
+    pub(super) row_count: i64,
     pub(super) row_range_start: i64,
     pub(super) row_range_end: i64,
     pub(super) external_path: Option<String>,
diff --git a/crates/paimon/src/table/global_index_scanner/evaluator.rs 
b/crates/paimon/src/table/global_index_scanner/evaluator.rs
index 9b2daea1..b2ef50e5 100644
--- a/crates/paimon/src/table/global_index_scanner/evaluator.rs
+++ b/crates/paimon/src/table/global_index_scanner/evaluator.rs
@@ -17,6 +17,7 @@
 
 //! Predicate orchestration and index-query planning.
 
+use super::all_match::all_matching_entries;
 use super::entry::{
     bitmap_meta_may_match, bitmap_meta_may_match_between, 
multivalue_meta_may_match,
     sorted_entry_meta, GlobalIndexEntry, GlobalIndexFileKind,
@@ -43,7 +44,6 @@ use crate::table::bitmap_global_index_format::{
 use crate::table::RowRange;
 use crate::{Error, Result};
 use futures::{StreamExt, TryStreamExt};
-use roaring::RoaringTreemap;
 use std::collections::HashSet;
 use std::future::Future;
 
@@ -299,15 +299,60 @@ impl GlobalIndexScanner {
             })
             .collect::<Result<Vec<_>>>()?;
         let predicates = normalized_predicates.as_slice();
-
         // Try to detect between pattern and split into (between, remaining)
         let (between, remaining) = extract_between(predicates);
 
+        let coverage = || {
+            crate::table::merge_row_ranges(
+                entries
+                    .iter()
+                    .map(|entry| RowRange::new(entry.row_range_start, 
entry.row_range_end))
+                    .collect(),
+            )
+        };
+        if between.as_ref().is_some_and(|range| range.is_empty()) {
+            return Ok(Some((Vec::new(), coverage())));
+        }
+
         let effective_predicates = if between.is_some() {
             &remaining
         } else {
             predicates
         };
+        // A redundant conjunct is an identity independently of the other
+        // predicates on this field. Do not decode its entire posting list.
+        let predicate_all_matches = all_matching_entries(entries, 
effective_predicates);
+        let between_all_matches = between
+            .as_ref()
+            .map(|range| {
+                let endpoints = [
+                    (
+                        if range.from_inclusive {
+                            PredicateOperator::GtEq
+                        } else {
+                            PredicateOperator::Gt
+                        },
+                        std::slice::from_ref(range.from),
+                        range.data_type,
+                    ),
+                    (
+                        if range.to_inclusive {
+                            PredicateOperator::LtEq
+                        } else {
+                            PredicateOperator::Lt
+                        },
+                        std::slice::from_ref(range.to),
+                        range.data_type,
+                    ),
+                ];
+                let matches = all_matching_entries(entries, &endpoints);
+                matches[0]
+                    .iter()
+                    .zip(&matches[1])
+                    .map(|(lower, upper)| *lower && *upper)
+                    .collect::<Vec<_>>()
+            })
+            .unwrap_or_else(|| vec![false; entries.len()]);
 
         // Pre-compute comparators and serialized keys for file-level pruning 
per predicate
         let pruning_info: Vec<_> = effective_predicates
@@ -367,8 +412,16 @@ impl GlobalIndexScanner {
             .iter()
             .enumerate()
             .map(|(i, (op, _, _))| {
-                requires_fallback_scan(*op)
-                    .then(|| self.fallback_scan_plan(entries, 
&predicate_matches[i]))
+                requires_fallback_scan(*op).then(|| {
+                    self.fallback_scan_plan(
+                        entries,
+                        &predicate_matches[i]
+                            .iter()
+                            .zip(&predicate_all_matches[i])
+                            .map(|(matches, all)| *matches && !all)
+                            .collect::<Vec<_>>(),
+                    )
+                })
             })
             .collect();
 
@@ -399,19 +452,36 @@ impl GlobalIndexScanner {
                 }
                 None => Vec::new(),
             };
-        let between_fallback_plan = between
-            .as_ref()
-            .map(|_| self.fallback_scan_plan(entries, 
&between_matches_by_entry));
+        let between_fallback_plan = between.as_ref().map(|_| {
+            self.fallback_scan_plan(
+                entries,
+                &between_matches_by_entry
+                    .iter()
+                    .zip(&between_all_matches)
+                    .map(|(matches, all)| *matches && !all)
+                    .collect::<Vec<_>>(),
+            )
+        });
 
         let mut query_plans = Vec::with_capacity(entries.len());
+        let mut all_matching_ranges = Vec::new();
         for (entry_idx, entry) in entries.iter().enumerate() {
+            if (between.is_none() || between_all_matches[entry_idx])
+                && predicate_all_matches
+                    .iter()
+                    .all(|matches| matches[entry_idx])
+            {
+                all_matching_ranges.push(RowRange::new(entry.row_range_start, 
entry.row_range_end));
+                continue;
+            }
             // Also check if between range may match
             let between_matches = between
                 .as_ref()
                 .is_some_and(|_| between_matches_by_entry[entry_idx]);
-            let between_evaluated_for_entry = 
between_fallback_plan.is_some_and(|plan| {
-                fallback_plan_evaluates_entry(plan, entry.index_type, 
between_matches)
-            });
+            let between_evaluated_for_entry = between_all_matches[entry_idx]
+                || between_fallback_plan.is_some_and(|plan| {
+                    fallback_plan_evaluates_entry(plan, entry.index_type, 
between_matches)
+                });
 
             // When a Between conjunct exists but the file does not overlap its
             // range, the whole AND cannot match — drop the file regardless of
@@ -430,6 +500,10 @@ impl GlobalIndexScanner {
                 between_matches && !between_evaluated_for_entry && 
between_fallback_plan.is_some();
             let matching_predicates: Vec<usize> = 
(0..effective_predicates.len())
                 .filter(|&i| {
+                    if predicate_all_matches[i][entry_idx] {
+                        file_evaluated = true;
+                        return false;
+                    }
                     let predicate_matches_entry = 
predicate_matches[i][entry_idx];
                     let predicate_evaluated_for_entry =
                         predicate_fallback_plans[i].is_none_or(|plan| {
@@ -461,10 +535,18 @@ impl GlobalIndexScanner {
                 continue;
             }
 
+            let query_between = between_evaluated_for_entry && 
!between_all_matches[entry_idx];
+            if !query_between && matching_predicates.is_empty() {
+                // Only a proven identity remains (other conjuncts may have
+                // declined their budget). Preserve its indexed domain and
+                // leave the residual filter to the ordinary table reader.
+                all_matching_ranges.push(RowRange::new(entry.row_range_start, 
entry.row_range_end));
+                continue;
+            }
             query_plans.push(EntryQueryPlan {
                 entry_idx,
                 between_matches,
-                between_evaluated: between_evaluated_for_entry,
+                between_evaluated: query_between,
                 matching_predicates,
             });
         }
@@ -495,21 +577,30 @@ impl GlobalIndexScanner {
                 let result = self
                     .query_entry(entry, data_type, between, &plan, 
effective_predicates)
                     .await?;
-                Ok((entry.row_range_start, result))
+                let ranges = result.bitmap.as_ref().map(|bitmap| {
+                    bitmap_to_ranges(bitmap)
+                        .into_iter()
+                        .map(|range| {
+                            RowRange::new(
+                                range.from() + entry.row_range_start,
+                                range.to() + entry.row_range_start,
+                            )
+                        })
+                        .collect::<Vec<_>>()
+                });
+                Ok((ranges, result.declined))
             });
-        let (all_row_ids, declined) = try_fold_bounded(
+        let (all_ranges, declined) = try_fold_bounded(
             futures,
             self.global_index_thread_num,
-            (RoaringTreemap::new(), false),
-            |(all_row_ids, declined), (row_range_start, file_result)| {
-                if file_result.declined {
+            (all_matching_ranges, false),
+            |(all_ranges, declined), (ranges, file_declined)| {
+                if file_declined {
                     *declined = true;
                     return;
                 }
-                if let Some(bitmap) = file_result.bitmap {
-                    for row_id in bitmap.iter() {
-                        all_row_ids.insert(row_id + row_range_start as u64);
-                    }
+                if let Some(ranges) = ranges {
+                    all_ranges.extend(ranges);
                 }
             },
         )
@@ -519,13 +610,10 @@ impl GlobalIndexScanner {
             return Ok(None);
         }
 
-        let coverage = crate::table::merge_row_ranges(
-            entries
-                .iter()
-                .map(|entry| RowRange::new(entry.row_range_start, 
entry.row_range_end))
-                .collect(),
-        );
-        Ok(Some((bitmap_to_ranges(&all_row_ids), coverage)))
+        Ok(Some((
+            crate::table::merge_row_ranges(all_ranges),
+            coverage(),
+        )))
     }
 
     fn find_field_id_by_name(&self, column: &str) -> Result<Option<i32>> {
diff --git a/crates/paimon/src/table/global_index_scanner/reader.rs 
b/crates/paimon/src/table/global_index_scanner/reader.rs
index b74b4cde..246eeccd 100644
--- a/crates/paimon/src/table/global_index_scanner/reader.rs
+++ b/crates/paimon/src/table/global_index_scanner/reader.rs
@@ -113,6 +113,12 @@ impl GlobalIndexScanner {
         let mut file_result = None;
 
         if plan.between_matches && plan.between_evaluated {
+            #[cfg(test)]
+            if let Some(probe) = &self.query_io_probe {
+                probe
+                    .range_queries
+                    .fetch_add(1, super::TestOrdering::SeqCst);
+            }
             let between = between.expect("evaluated between query is present");
             let serialize_key = match entry.index_type {
                 GlobalIndexFileKind::BTree => serialize_datum,
@@ -139,6 +145,12 @@ impl GlobalIndexScanner {
         }
 
         for &idx in &plan.matching_predicates {
+            #[cfg(test)]
+            if let Some(probe) = &self.query_io_probe {
+                probe
+                    .predicate_queries
+                    .fetch_add(1, super::TestOrdering::SeqCst);
+            }
             let (op, literals, data_type) = &effective_predicates[idx];
             let Some(bitmap) = reader
                 .as_ref()
diff --git a/crates/paimon/src/table/global_index_scanner/row_ranges.rs 
b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
index abfe5df7..a306d88a 100644
--- a/crates/paimon/src/table/global_index_scanner/row_ranges.rs
+++ b/crates/paimon/src/table/global_index_scanner/row_ranges.rs
@@ -24,32 +24,30 @@ use std::collections::{HashMap, HashSet};
 
 /// Convert a RoaringTreemap to merged RowRanges (already sorted and 
deduplicated).
 pub(super) fn bitmap_to_ranges(bitmap: &RoaringTreemap) -> Vec<RowRange> {
-    if bitmap.is_empty() {
-        return Vec::new();
-    }
-    let mut ranges = Vec::new();
-    let mut iter = bitmap.iter();
-    let first = iter.next().unwrap();
-    let mut start = first as i64;
-    let mut end = start;
-
-    for id in iter {
-        let id = id as i64;
-        if id == end + 1 {
-            end = id;
-        } else {
+    let mut ranges: Vec<RowRange> = Vec::new();
+    // Walk containers/runs instead of every set bit. Adjacent high-32 
partitions
+    // must still be joined to preserve the canonical range layout.
+    for (high, low) in bitmap.bitmaps() {
+        let base = u64::from(high) << 32;
+        let mut iter = low.iter();
+        while let Some(range) = iter.next_range() {
+            let start = (base | u64::from(*range.start())) as i64;
+            let end = (base | u64::from(*range.end())) as i64;
+            if let Some(last) = ranges.last_mut() {
+                if last.to().checked_add(1) == Some(start) {
+                    *last = RowRange::new(last.from(), end);
+                    continue;
+                }
+            }
             ranges.push(RowRange::new(start, end));
-            start = id;
-            end = id;
         }
     }
-    ranges.push(RowRange::new(start, end));
     ranges
 }
 
 /// Intersect two sorted range lists using RowRangeIndex for efficient binary 
search.
 pub(super) fn intersect_sorted_ranges(a: &[RowRange], b: &[RowRange]) -> 
Vec<RowRange> {
-    let idx = RowRangeIndex::create(a.to_vec());
+    let idx = RowRangeIndex::from_sorted_ranges(a.to_vec());
     let mut result = Vec::new();
     for r in b {
         result.extend(idx.intersected_ranges(r.from(), r.to()));
@@ -187,6 +185,11 @@ impl RowRangeIndex {
     /// Ranges are sorted and merged to eliminate overlaps.
     pub fn create(ranges: Vec<RowRange>) -> Self {
         let ranges = merge_row_ranges(ranges);
+        Self::from_sorted_ranges(ranges)
+    }
+
+    // Internal callers with canonical ranges must not sort and merge them 
again.
+    fn from_sorted_ranges(ranges: Vec<RowRange>) -> Self {
         let starts: Vec<i64> = ranges.iter().map(|r| r.from()).collect();
         let ends: Vec<i64> = ranges.iter().map(|r| r.to()).collect();
         Self {
diff --git a/crates/paimon/src/table/global_index_scanner/tests.rs 
b/crates/paimon/src/table/global_index_scanner/tests.rs
index 6c3d12f8..4bd731ab 100644
--- a/crates/paimon/src/table/global_index_scanner/tests.rs
+++ b/crates/paimon/src/table/global_index_scanner/tests.rs
@@ -95,6 +95,115 @@ fn test_intersect_sorted_ranges() {
     assert_eq!(result, vec![RowRange::new(5, 10), RowRange::new(20, 25)]);
 }
 
+#[test]
+fn test_bitmap_ranges_across_containers_and_high_keys() {
+    for run in [1u64, 9, 31, 64, 256, 65536] {
+        let start = (1u64 << 32) - 10000;
+        let expected: Vec<_> = (0..100)
+            .map(|i| {
+                let from = start + i * (run + 1);
+                RowRange::new(from as i64, (from + run - 1) as i64)
+            })
+            .collect();
+        let mut bitmap = RoaringTreemap::new();
+        for range in &expected {
+            bitmap.insert_range(range.from() as u64..=range.to() as u64);
+        }
+        assert_eq!(bitmap_to_ranges(&bitmap), expected);
+        bitmap.optimize();
+        assert_eq!(bitmap_to_ranges(&bitmap), expected);
+    }
+    let mut bitmap = RoaringTreemap::new();
+    bitmap.insert_range((1u64 << 32) - 10..=(1u64 << 32) + 10);
+    bitmap.insert(i64::MAX as u64);
+    assert_eq!(
+        bitmap_to_ranges(&bitmap),
+        vec![
+            RowRange::new((1i64 << 32) - 10, (1i64 << 32) + 10),
+            RowRange::new(i64::MAX, i64::MAX),
+        ]
+    );
+}
+
+#[test]
+#[ignore = "manual release benchmark; no wall-clock assertions"]
+fn benchmark_bitmap_range_transport() {
+    use std::hint::black_box;
+    use std::time::Instant;
+    fn baseline(bitmap: &RoaringTreemap, offset: u64) -> Vec<RowRange> {
+        let mut shifted = RoaringTreemap::new();
+        for id in bitmap {
+            shifted.insert(id + offset);
+        }
+        let mut ranges = Vec::new();
+        let mut current: Option<(i64, i64)> = None;
+        for id in shifted {
+            let id = id as i64;
+            match current {
+                Some((start, end)) if id == end + 1 => current = Some((start, 
id)),
+                Some((start, end)) => {
+                    ranges.push(RowRange::new(start, end));
+                    current = Some((id, id));
+                }
+                None => current = Some((id, id)),
+            }
+        }
+        if let Some((start, end)) = current {
+            ranges.push(RowRange::new(start, end));
+        }
+        ranges
+    }
+    fn optimized(bitmap: &RoaringTreemap, offset: u64) -> Vec<RowRange> {
+        crate::table::merge_row_ranges(
+            bitmap_to_ranges(bitmap)
+                .into_iter()
+                .map(|r| RowRange::new(r.from() + offset as i64, r.to() + 
offset as i64))
+                .collect(),
+        )
+    }
+    for (name, count, run) in [
+        ("continuous", 12_000_000u64, 12_000_000),
+        ("nine-hit-one-gap", 1_200_000, 9),
+        ("singletons", 1_200_000, 1),
+    ] {
+        let mut bitmap = RoaringTreemap::new();
+        let step = if run == 1 { 10 } else { run + 1 };
+        for start in (0..count).step_by(step as usize) {
+            bitmap.insert_range(start..(start + run).min(count));
+        }
+        bitmap.optimize();
+        let offset = (1u64 << 32) - 10000;
+        let expected = baseline(&bitmap, offset);
+        assert_eq!(optimized(&bitmap, offset), expected);
+        for _ in 0..3 {
+            black_box(baseline(&bitmap, offset));
+            black_box(optimized(&bitmap, offset));
+        }
+        let mut times = [Vec::new(), Vec::new()];
+        for round in 0..7 {
+            for index in [round % 2, 1 - round % 2] {
+                let started = Instant::now();
+                black_box(if index == 0 {
+                    baseline(&bitmap, offset)
+                } else {
+                    optimized(&bitmap, offset)
+                });
+                times[index].push(started.elapsed().as_secs_f64() * 1000.0);
+            }
+        }
+        for samples in &mut times {
+            samples.sort_by(f64::total_cmp);
+        }
+        println!(
+            "RANGE_BENCH {name} rows={} ranges={} before_ms={:.3} 
after_ms={:.3}",
+            bitmap.len(),
+            expected.len(),
+            times[0][3],
+            times[1][3]
+        );
+    }
+}
+
 #[test]
 fn test_intersect_no_overlap() {
     let a = vec![RowRange::new(0, 5)];
@@ -496,6 +605,7 @@ fn test_mixed_fm_and_btree_select_compatible_index_family() 
{
         file_name: "name.btree".to_string(),
         index_type: GlobalIndexFileKind::BTree,
         file_size: 1,
+        row_count: 10,
         row_range_start: 0,
         row_range_end: 9,
         external_path: None,
@@ -505,6 +615,7 @@ fn test_mixed_fm_and_btree_select_compatible_index_family() 
{
         file_name: "name.fm".to_string(),
         index_type: GlobalIndexFileKind::FM,
         file_size: 1,
+        row_count: 10,
         row_range_start: 0,
         row_range_end: 9,
         external_path: None,
@@ -615,6 +726,535 @@ fn int_eq(column: &str, index: usize, value: i32) -> 
Predicate {
     }
 }
 
+#[tokio::test]
+async fn test_btree_all_match_without_index_io() {
+    let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let fields = int_schema_fields();
+    let meta = BTreeIndexMeta::new(
+        Some(7i32.to_le_bytes().to_vec()),
+        Some(7i32.to_le_bytes().to_vec()),
+        false,
+    );
+    let mut entry = make_global_index_entry("not-created.index", 1, 100, 109, 
&meta);
+    entry.index_file.row_count = 10;
+    entry.index_file.file_size = i64::MAX;
+    for (op, values) in [
+        (PredicateOperator::Eq, vec![7]),
+        (PredicateOperator::Lt, vec![8]),
+        (PredicateOperator::LtEq, vec![7]),
+        (PredicateOperator::Gt, vec![6]),
+        (PredicateOperator::GtEq, vec![7]),
+        (PredicateOperator::Between, vec![7, 7]),
+    ] {
+        let predicate = Predicate::Leaf {
+            column: "id".into(),
+            index: 0,
+            data_type: fields[0].data_type().clone(),
+            op,
+            literals: values.into_iter().map(Datum::Int).collect(),
+        };
+        let ranges = evaluate_global_index_fast_with_fallback_size(
+            &io,
+            "memory:/absent",
+            std::slice::from_ref(&entry),
+            &[predicate],
+            &fields,
+            0,
+            0,
+        )
+        .await
+        .unwrap();
+        assert_eq!(ranges, Some(vec![RowRange::new(100, 109)]), "{op}");
+    }
+    for ops in [
+        vec![PredicateOperator::Lt],
+        vec![PredicateOperator::Gt],
+        vec![PredicateOperator::Eq, PredicateOperator::Gt],
+    ] {
+        let predicates = ops
+            .into_iter()
+            .map(|op| Predicate::Leaf {
+                column: "id".into(),
+                index: 0,
+                data_type: fields[0].data_type().clone(),
+                op,
+                literals: vec![Datum::Int(7)],
+            })
+            .collect();
+        assert_eq!(
+            evaluate_global_index_fast(
+                &io,
+                "memory:/absent",
+                std::slice::from_ref(&entry),
+                &[Predicate::and(predicates)],
+                &fields,
+            )
+            .await
+            .unwrap(),
+            Some(vec![])
+        );
+    }
+    // Matching value bounds alone must not invent rows for incomplete/unknown 
coverage.
+    for count in [-1, 0, 9, 11] {
+        entry.index_file.row_count = count;
+        assert!(evaluate_global_index_fast(
+            &io,
+            "memory:/absent",
+            std::slice::from_ref(&entry),
+            &[int_eq("id", 0, 7)],
+            &fields
+        )
+        .await
+        .is_err());
+    }
+}
+
+#[tokio::test]
+async fn test_all_match_does_not_hide_partial_shard_over_budget() {
+    let (io, path, file, tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let fields = int_schema_fields();
+    let mut all = make_global_index_entry(
+        "absent.index",
+        1,
+        100,
+        199,
+        &BTreeIndexMeta::new(Some(le_int_key(7)), Some(le_int_key(7)), false),
+    );
+    all.index_file.row_count = 100;
+    all.index_file.file_size = i64::MAX;
+    let mut partial = make_global_index_entry(
+        &file,
+        1,
+        300,
+        399,
+        &BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false),
+    );
+    let size = std::fs::metadata(tmp.path().join("index").join(&file))
+        .unwrap()
+        .len() as i64;
+    partial.index_file.file_size = size;
+    partial.index_file.row_count = 100;
+    let entries = vec![all, partial];
+    for between in [false, true] {
+        let mut predicates = vec![Predicate::Leaf {
+            column: "id".into(),
+            index: 0,
+            data_type: fields[0].data_type().clone(),
+            op: PredicateOperator::Lt,
+            literals: vec![Datum::Int(11)],
+        }];
+        if between {
+            predicates.push(Predicate::Leaf {
+                column: "id".into(),
+                index: 0,
+                data_type: fields[0].data_type().clone(),
+                op: PredicateOperator::Gt,
+                literals: vec![Datum::Int(2)],
+            });
+        }
+        for budget in [size, size - 1] {
+            let actual = evaluate_global_index_fast_with_fallback_size(
+                &io,
+                &path,
+                &entries,
+                &[Predicate::and(predicates.clone())],
+                &fields,
+                budget,
+                i64::MAX,
+            )
+            .await
+            .unwrap();
+            let expected = (budget == size).then(|| {
+                vec![
+                    RowRange::new(100, 199),
+                    RowRange::new(if between { 302 } else { 300 }, 305),
+                ]
+            });
+            assert_eq!(actual, expected, "between={between}, budget={budget}");
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_all_match_requires_every_key_shard() {
+    let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let path = "memory:/all_match_key_shards";
+    let mut entries = Vec::new();
+    for (key, rows) in [(7, 0..40), (9, 40..100)] {
+        let name = format!("key-{key}.index");
+        let buf = VecFileWrite::new();
+        let mut writer =
+            BTreeIndexWriter::new(Box::new(buf.clone()), 64, 
BlockCompressionType::None);
+        for id in rows {
+            writer.write(Some(&le_int_key(key)), id).await.unwrap();
+        }
+        let result = writer.finish().await.unwrap();
+        io.new_output(&format!("{path}/index/{name}"))
+            .unwrap()
+            .write(bytes::Bytes::from(buf.to_vec()))
+            .await
+            .unwrap();
+        let mut entry = make_global_index_entry(&name, 1, 100, 199, 
&result.meta);
+        entry.index_file.row_count = result.row_count as i64;
+        entries.push(entry);
+    }
+    assert_eq!(
+        evaluate_global_index_fast(
+            &io,
+            path,
+            &entries,
+            &[int_eq("id", 0, 7)],
+            &int_schema_fields()
+        )
+        .await
+        .unwrap(),
+        Some(vec![RowRange::new(100, 139)])
+    );
+}
+
+#[tokio::test]
+async fn test_btree_all_match_coverage_nulls_and_boolean_siblings() {
+    let (io, path, file, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let meta = BTreeIndexMeta::new(Some(le_int_key(7)), Some(le_int_key(7)), 
false);
+    let mut all = make_global_index_entry("absent.index", 1, 100, 199, &meta);
+    all.index_file.row_count = 100;
+    let selective = make_global_index_entry(
+        &file,
+        2,
+        100,
+        199,
+        &BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false),
+    );
+    let fields = two_field_schema_fields();
+    let predicates = vec![int_eq("id", 0, 7), int_eq("value", 1, 18)];
+    let entries = vec![all.clone(), selective];
+    assert_eq!(
+        evaluate_global_index_fast(
+            &io,
+            &path,
+            &entries,
+            &[Predicate::and(predicates.clone())],
+            &fields
+        )
+        .await
+        .unwrap(),
+        Some(vec![RowRange::new(109, 109)])
+    );
+    assert_eq!(
+        evaluate_global_index_fast(&io, &path, &entries, 
&[Predicate::or(predicates)], &fields)
+            .await
+            .unwrap(),
+        Some(vec![RowRange::new(100, 199)])
+    );
+
+    for (mode, expected) in [
+        (GlobalIndexSearchMode::Fast, vec![RowRange::new(100, 199)]),
+        (GlobalIndexSearchMode::Full, vec![RowRange::new(0, 219)]),
+        (GlobalIndexSearchMode::Detail, vec![RowRange::new(100, 209)]),
+    ] {
+        let actual = evaluate_global_index(GlobalIndexEvaluation {
+            file_io: &io,
+            table_path: &path,
+            index_entries: std::slice::from_ref(&all),
+            predicates: &[int_eq("id", 0, 7)],
+            schema_fields: &fields,
+            search_mode: mode,
+            global_index_thread_num: 2,
+            btree_fallback_scan_max_size: 0,
+            bitmap_fallback_scan_max_size: 0,
+            fm_read_options: FMReadOptions::default(),
+            next_row_id: Some(220),
+            data_ranges: &[RowRange::new(100, 209)],
+        })
+        .await
+        .unwrap();
+        assert_eq!(actual, Some(expected));
+    }
+
+    // Two key-partitioned files share a source domain; neither alone is 
complete.
+    let mut first = all.clone();
+    first.index_file.row_count = 40;
+    let mut second = all.clone();
+    second.index_file.file_name = "also-absent.index".into();
+    second.index_file.row_count = 60;
+    assert_eq!(
+        evaluate_global_index_fast(
+            &io,
+            &path,
+            &[first.clone(), second.clone()],
+            &[int_eq("id", 0, 7)],
+            &fields
+        )
+        .await
+        .unwrap(),
+        Some(vec![RowRange::new(100, 199)])
+    );
+    assert!(
+        evaluate_global_index_fast(&io, &path, &[first], &[int_eq("id", 0, 
7)], &fields)
+            .await
+            .is_err()
+    );
+    let nullable = BTreeIndexMeta::new(Some(le_int_key(7)), 
Some(le_int_key(7)), true);
+    all.index_file
+        .global_index_meta
+        .as_mut()
+        .unwrap()
+        .index_meta = Some(nullable.serialize());
+    assert!(
+        evaluate_global_index_fast(&io, &path, &[all], &[int_eq("id", 0, 7)], 
&fields)
+            .await
+            .is_err()
+    );
+}
+
+#[tokio::test]
+async fn test_bounded_range_all_endpoints_and_predicate_orders() {
+    let (io, path, file, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let fields = int_schema_fields();
+    let entries = vec![make_global_index_entry(
+        &file,
+        1,
+        100,
+        199,
+        &BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false),
+    )];
+    for (low, low_inclusive) in [
+        (PredicateOperator::Gt, false),
+        (PredicateOperator::GtEq, true),
+    ] {
+        for (high, high_inclusive) in [
+            (PredicateOperator::Lt, false),
+            (PredicateOperator::LtEq, true),
+        ] {
+            for (lower, upper) in [(30, 40), (30, 30), (40, 30)] {
+                for reverse in [false, true] {
+                    let mut predicates: Vec<_> = [(low, lower), (high, upper)]
+                        .into_iter()
+                        .map(|(op, v)| Predicate::Leaf {
+                            column: "id".into(),
+                            index: 0,
+                            data_type: fields[0].data_type().clone(),
+                            op,
+                            literals: vec![Datum::Int(v)],
+                        })
+                        .collect();
+                    if reverse {
+                        predicates.reverse();
+                    }
+                    let actual = evaluate_global_index_fast(
+                        &io,
+                        &path,
+                        &entries,
+                        &[Predicate::and(predicates)],
+                        &fields,
+                    )
+                    .await
+                    .unwrap()
+                    .unwrap();
+                    let expected: Vec<_> = (0..100)
+                        .filter(|id| {
+                            let key = *id * 2;
+                            (key > lower || low_inclusive && key == lower)
+                                && (key < upper || high_inclusive && key == 
upper)
+                        })
+                        .map(|id| id as i64 + 100)
+                        .collect();
+                    let actual: Vec<_> = actual.iter().flat_map(|r| 
r.from()..=r.to()).collect();
+                    assert_eq!(actual, expected);
+                }
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_scalar_optimization_query_counts() {
+    let (io, path, file, _tmp) = 
setup_testdata_table("btree_int_100_no_compress.bin");
+    let fields = int_schema_fields();
+    let mut entry = make_global_index_entry(
+        &file,
+        1,
+        100,
+        199,
+        &BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false),
+    );
+    entry.index_file.row_count = 100;
+    let leaf = |op, value| Predicate::Leaf {
+        column: "id".into(),
+        index: 0,
+        data_type: fields[0].data_type().clone(),
+        op,
+        literals: vec![Datum::Int(value)],
+    };
+    for (conditions, expected, query_counts) in [
+        (
+            vec![
+                leaf(PredicateOperator::GtEq, 0),
+                leaf(PredicateOperator::Eq, 50),
+            ],
+            vec![RowRange::new(125, 125)],
+            (1, 0),
+        ),
+        (
+            vec![
+                leaf(PredicateOperator::GtEq, 10),
+                leaf(PredicateOperator::LtEq, 180),
+                leaf(PredicateOperator::Gt, 30),
+                leaf(PredicateOperator::Lt, 40),
+            ],
+            vec![RowRange::new(116, 119)],
+            (0, 1),
+        ),
+        (
+            vec![
+                leaf(PredicateOperator::Gt, 40),
+                leaf(PredicateOperator::Lt, 30),
+            ],
+            vec![],
+            (0, 0),
+        ),
+        (
+            vec![
+                leaf(PredicateOperator::Gt, 30),
+                leaf(PredicateOperator::LtEq, 30),
+            ],
+            vec![],
+            (0, 0),
+        ),
+    ] {
+        for reverse in [false, true] {
+            let mut conditions = conditions.clone();
+            if reverse {
+                conditions.reverse();
+            }
+            let mut scanner = GlobalIndexScanner::create(
+                &io,
+                &path,
+                2,
+                i64::MAX,
+                i64::MAX,
+                std::slice::from_ref(&entry),
+                &fields,
+            )
+            .unwrap()
+            .unwrap();
+            let probe = Arc::new(QueryIoProbe::default());
+            scanner.query_io_probe = Some(probe.clone());
+            let result = scanner
+                .evaluate(&Predicate::and(conditions))
+                .await
+                .unwrap()
+                .unwrap();
+            assert_eq!(result.row_ranges, expected);
+            assert_eq!(
+                (
+                    probe.predicate_queries.load(TestOrdering::SeqCst),
+                    probe.range_queries.load(TestOrdering::SeqCst)
+                ),
+                query_counts
+            );
+            if query_counts == (0, 0) {
+                assert_eq!(probe.peak(), 0);
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_scalar_optimization_empty_range_never_opens_index() {
+    let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let fields = int_schema_fields();
+    let entry = make_global_index_entry(
+        "must-not-open.index",
+        1,
+        100,
+        199,
+        &BTreeIndexMeta::new(Some(le_int_key(0)), Some(le_int_key(198)), 
false),
+    );
+    for (from, to) in [(40, 30), (30, 30)] {
+        let predicates: Vec<_> = [(PredicateOperator::Gt, from), 
(PredicateOperator::LtEq, to)]
+            .into_iter()
+            .map(|(op, v)| Predicate::Leaf {
+                column: "id".into(),
+                index: 0,
+                data_type: fields[0].data_type().clone(),
+                op,
+                literals: vec![Datum::Int(v)],
+            })
+            .collect();
+        for (mode, expected) in [
+            (GlobalIndexSearchMode::Fast, vec![]),
+            (
+                GlobalIndexSearchMode::Full,
+                vec![RowRange::new(0, 99), RowRange::new(200, 219)],
+            ),
+            (GlobalIndexSearchMode::Detail, vec![RowRange::new(200, 209)]),
+        ] {
+            let actual = evaluate_global_index(GlobalIndexEvaluation {
+                file_io: &io,
+                table_path: "memory:/absent",
+                index_entries: std::slice::from_ref(&entry),
+                predicates: &[Predicate::and(predicates.clone())],
+                schema_fields: &fields,
+                search_mode: mode,
+                global_index_thread_num: 2,
+                btree_fallback_scan_max_size: 0,
+                bitmap_fallback_scan_max_size: 0,
+                fm_read_options: FMReadOptions::default(),
+                next_row_id: Some(220),
+                data_ranges: &[RowRange::new(100, 209)],
+            })
+            .await
+            .unwrap();
+            assert_eq!(actual, Some(expected));
+        }
+    }
+}
+
+#[tokio::test]
+async fn 
test_all_matching_conjunct_keeps_domain_when_other_conjunct_declines() {
+    let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+    let fields = string_schema_fields();
+    let mut entry = make_global_index_entry(
+        "must-not-open.index",
+        1,
+        100,
+        109,
+        &BTreeIndexMeta::new(Some(b"hit".to_vec()), Some(b"miss".to_vec()), 
false),
+    );
+    entry.index_file.row_count = 10;
+    entry.index_file.file_size = 100;
+    let predicates = [
+        (PredicateOperator::Lt, "z"),
+        (PredicateOperator::Contains, "hit"),
+    ]
+    .into_iter()
+    .map(|(op, value)| Predicate::Leaf {
+        column: "name".into(),
+        index: 0,
+        data_type: fields[0].data_type().clone(),
+        op,
+        literals: vec![Datum::String(value.into())],
+    })
+    .collect();
+    let actual = evaluate_global_index_fast_with_fallback_size(
+        &io,
+        "memory:/absent",
+        &[entry],
+        &[Predicate::and(predicates)],
+        &fields,
+        0,
+        0,
+    )
+    .await
+    .unwrap();
+    assert_eq!(
+        actual,
+        Some(vec![RowRange::new(100, 109)]),
+        "retain candidates for residual filtering, not empty"
+    );
+}
+
 #[test]
 fn test_unindexed_ranges_fast_mode_empty() {
     let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
@@ -1191,6 +1831,99 @@ async fn 
test_bitmap_nan_equality_uses_direct_lookup_with_fallback_scan_disabled
     .await;
 }
 
+#[tokio::test]
+async fn test_bitmap_floating_bound_fusion_preserves_candidates_and_fallback() 
{
+    for (ty, low, high, zero, minus_one) in [
+        (
+            DataType::Float(crate::spec::FloatType::new()),
+            Datum::Float(f32::from_bits(0xffc0_0001)),
+            Datum::Float(f32::from_bits(0x7fc0_0001)),
+            Datum::Float(0.0),
+            Datum::Float(-1.0),
+        ),
+        (
+            DataType::Double(crate::spec::DoubleType::new()),
+            Datum::Double(f64::from_bits(0xfff8_0000_0000_0001)),
+            Datum::Double(f64::from_bits(0x7ff8_0000_0000_0001)),
+            Datum::Double(0.0),
+            Datum::Double(-1.0),
+        ),
+    ] {
+        let output = VecFileWrite::new();
+        let mut writer = BitmapGlobalIndexWriter::new(
+            Box::new(output.clone()),
+            1,
+            BlockCompressionType::None,
+            make_bitmap_key_comparator(&ty),
+        );
+        writer
+            .write(Some(&serialize_bitmap_datum(&zero, &ty)), 0)
+            .unwrap();
+        writer.write(None, 1).unwrap();
+        let result = writer.finish().await.unwrap();
+        let bytes = output.to_vec();
+        let io = crate::io::FileIOBuilder::new("memory").build().unwrap();
+        let path = "memory:/bitmap_bound_fusion";
+        io.new_output(&format!("{path}/index/bitmap.index"))
+            .unwrap()
+            .write(bytes::Bytes::from(bytes.clone()))
+            .await
+            .unwrap();
+        let mut entry = make_global_index_entry_with_type(
+            BITMAP_GLOBAL_INDEX_TYPE,
+            "bitmap.index",
+            1,
+            100,
+            101,
+            &result.meta,
+        );
+        entry.index_file.file_size = bytes.len() as i64;
+        let fields = vec![DataField::new(1, "id".into(), ty.clone())];
+        let leaf = |op, value: Datum| Predicate::Leaf {
+            column: "id".into(),
+            index: 0,
+            data_type: ty.clone(),
+            op,
+            literals: vec![value],
+        };
+        for extra_bound in [false, true] {
+            for reverse in [false, true] {
+                let mut conditions = vec![
+                    leaf(PredicateOperator::Gt, low.clone()),
+                    leaf(PredicateOperator::Lt, high.clone()),
+                ];
+                if extra_bound {
+                    conditions.push(leaf(PredicateOperator::Gt, 
minus_one.clone()));
+                }
+                if reverse {
+                    conditions.reverse();
+                }
+                for budget in [i64::MAX, 0] {
+                    let actual = evaluate_global_index_fast_with_fallback_size(
+                        &io,
+                        path,
+                        std::slice::from_ref(&entry),
+                        &[Predicate::and(conditions.clone())],
+                        &fields,
+                        0,
+                        budget,
+                    )
+                    .await
+                    .unwrap();
+                    assert_eq!(
+                        actual,
+                        if budget == 0 {
+                            None
+                        } else {
+                            Some(vec![RowRange::new(100, 100)])
+                        }
+                    );
+                }
+            }
+        }
+    }
+}
+
 fn legacy_floating_comparator(data_type: &DataType) -> BoxedCmp {
     match data_type {
         DataType::Float(_) => Box::new(|left, right| {
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 fff014d5..cace6bec 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
@@ -657,6 +657,22 @@ async fn scan_ids(table: &Table, predicate: Predicate) -> 
Vec<i32> {
         .try_collect::<Vec<_>>()
         .await
         .unwrap();
+    // The column-group fixture must merge payloads by RowID, not just return
+    // the right number of rows from the indexed key/flag group.
+    for batch in &batches {
+        if let Some(payload) = batch.column_by_name("payload") {
+            let ids = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            let payload = 
payload.as_any().downcast_ref::<Int64Array>().unwrap();
+            assert_eq!(payload.null_count(), 0);
+            for row in 0..batch.num_rows() {
+                assert_eq!(payload.value(row), i64::from(ids.value(row)) * 17);
+            }
+        }
+    }
     let mut ids = batches
         .iter()
         .flat_map(|batch| {
@@ -676,8 +692,15 @@ async fn scan_ids(table: &Table, predicate: Predicate) -> 
Vec<i32> {
 
 #[tokio::test]
 async fn test_execute_writes_btree_index_manifest_and_file() {
+    for version in [1, 2] {
+        assert_btree_build_version(version).await;
+    }
+}
+
+async fn assert_btree_build_version(version: u32) {
     let table_path = "memory:/test_btree_global_index_builder_e2e";
     let mut options = table_options("10");
+    options.insert("btree-index.file-version".into(), version.to_string());
     options.insert(
         "btree-index.bloom-filter.enabled".to_string(),
         "true".to_string(),
@@ -742,6 +765,7 @@ async fn 
test_execute_writes_btree_index_manifest_and_file() {
         .bloom_filter_handle
         .expect("enabled BTree build must write a Bloom filter");
     assert_eq!(bloom_handle.expected_entries, 2);
+    assert_eq!(footer.version, version);
 
     let global_meta = index_file
         .global_index_meta
@@ -805,6 +829,230 @@ async fn 
test_execute_writes_btree_index_manifest_and_file() {
         plan.splits()[0].row_ranges(),
         Some(&[RowRange::new(0, 0), RowRange::new(2, 2)][..])
     );
+    let predicate = PredicateBuilder::new(scan_table.schema().fields())
+        .equal("name", crate::spec::Datum::String("alice".to_string()))
+        .unwrap();
+    assert_eq!(scan_ids(&scan_table, predicate).await, vec![1, 3]);
+}
+
+async fn column_group_btree_table(rows: i32, version: u32) -> Table {
+    use crate::table::data_evolution_writer::DataEvolutionPartialWriter;
+    let mut options = table_options("2000000");
+    options.insert("btree-index.file-version".into(), version.to_string());
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("name", DataType::VarChar(VarCharType::string_type()))
+        .column("payload", DataType::BigInt(crate::spec::BigIntType::new()))
+        .options(options)
+        .build()
+        .unwrap();
+    let table = Table::new(
+        FileIOBuilder::new("memory").build().unwrap(),
+        Identifier::new("default", "column_groups"),
+        format!("memory:/btree_column_groups_v{version}"),
+        TableSchema::new(0, &schema),
+        None,
+    );
+    setup_dirs(&table).await;
+    let batch = data_batch(
+        (0..rows).collect(),
+        (0..rows)
+            .map(|id| if id % 10 == 0 { "miss" } else { "hit" })
+            .collect(),
+    );
+    let mut write =
+        DataEvolutionPartialWriter::new(&table, vec!["id".into(), 
"name".into()]).unwrap();
+    write
+        .write_partial_batch(
+            crate::spec::EMPTY_BINARY_ROW.to_serialized_bytes(),
+            0,
+            0,
+            0,
+            batch,
+        )
+        .await
+        .unwrap();
+    let mut messages = write.prepare_commit().await.unwrap();
+    // Seed an append column group: normal commit allocates its RowIDs.
+    // Subsequent partial-column writes reuse that committed RowID domain.
+    for message in &mut messages {
+        message.check_from_snapshot = None;
+        for file in &mut message.new_files {
+            file.first_row_id = None;
+        }
+    }
+    TableCommit::new(table.clone(), "bench".into())
+        .commit(messages)
+        .await
+        .unwrap();
+    let batch = RecordBatch::try_new(
+        Arc::new(ArrowSchema::new(vec![ArrowField::new(
+            "payload",
+            ArrowDataType::Int64,
+            true,
+        )])),
+        vec![Arc::new(Int64Array::from(
+            (0..rows).map(|id| i64::from(id) * 17).collect::<Vec<_>>(),
+        ))],
+    )
+    .unwrap();
+    let mut payload = DataEvolutionPartialWriter::new(&table, 
vec!["payload".into()]).unwrap();
+    payload
+        .write_partial_batch(
+            crate::spec::EMPTY_BINARY_ROW.to_serialized_bytes(),
+            0,
+            0,
+            1,
+            batch,
+        )
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "payload".into())
+        .commit(payload.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let files: Vec<_> = plan
+        .splits()
+        .iter()
+        .flat_map(|split| split.data_files())
+        .collect();
+    assert_eq!(files.len(), 2);
+    for file in &files {
+        assert!(file.file_name.ends_with(".parquet"));
+        assert_eq!(file.first_row_id, Some(0));
+        assert_eq!(file.row_count, i64::from(rows));
+    }
+    assert!(files
+        .iter()
+        .any(|file| file.write_cols.as_deref() == Some(&["id".into(), 
"name".into()])));
+    assert!(files
+        .iter()
+        .any(|file| file.write_cols.as_deref() == Some(&["payload".into()])));
+    for column in ["id", "name"] {
+        table
+            .new_btree_global_index_build_builder()
+            .with_index_column(column)
+            .execute()
+            .await
+            .unwrap();
+    }
+    table
+}
+
+#[tokio::test]
+async fn test_scalar_column_group_reads() {
+    for version in [1, 2] {
+        let table = column_group_btree_table(4096, version).await;
+        let builder = PredicateBuilder::new(table.schema().fields());
+        for (predicate, expected) in [
+            (
+                builder.equal("name", Datum::String("miss".into())).unwrap(),
+                (0..4096).step_by(10).collect(),
+            ),
+            (
+                Predicate::and(vec![
+                    builder.greater_or_equal("id", Datum::Int(0)).unwrap(),
+                    builder.equal("id", Datum::Int(1)).unwrap(),
+                ]),
+                vec![1],
+            ),
+        ] {
+            assert_eq!(scan_ids(&table, predicate).await, expected);
+        }
+    }
+}
+
+#[tokio::test]
+#[ignore = "manual release benchmark; default Parquet, column groups, planning 
and full reads"]
+async fn benchmark_btree_data_evolution_read() {
+    use crate::spec::Datum;
+    use std::time::Instant;
+    let rows = 1_200_000;
+    for version in [1, 2] {
+        let table = column_group_btree_table(rows, version).await;
+        let mut options = table.schema().options().clone();
+        options.insert("global-index.enabled".into(), "false".into());
+        let no_index = Table::new(
+            table.file_io().clone(),
+            table.identifier().clone(),
+            table.location().to_string(),
+            table.schema().copy_with_replaced_options(options),
+            None,
+        );
+        let builder = PredicateBuilder::new(table.schema().fields());
+        let point = builder.equal("id", Datum::Int(1)).unwrap();
+        let dense = builder.equal("name", 
Datum::String("hit".into())).unwrap();
+        let sparse = builder.equal("name", 
Datum::String("miss".into())).unwrap();
+        let all = builder
+            .less_than("name", Datum::String("z".into()))
+            .unwrap();
+        let bounded = Predicate::and(vec![
+            builder.greater_than("id", Datum::Int(rows / 2)).unwrap(),
+            builder.less_than("id", Datum::Int(rows / 2 + 100)).unwrap(),
+        ]);
+        let point_with_full_bound = Predicate::and(vec![
+            builder.greater_or_equal("id", Datum::Int(0)).unwrap(),
+            point.clone(),
+        ]);
+        let multiple_bounds = Predicate::and(vec![
+            builder.greater_or_equal("id", Datum::Int(0)).unwrap(),
+            builder.less_than("id", Datum::Int(rows)).unwrap(),
+            bounded.clone(),
+        ]);
+        for (name, predicate, expected) in [
+            (
+                "90pct",
+                dense.clone(),
+                (0..rows).filter(|id| id % 10 != 0).collect::<Vec<_>>(),
+            ),
+            (
+                "10pct",
+                sparse,
+                (0..rows).filter(|id| id % 10 == 0).collect(),
+            ),
+            ("point", point.clone(), vec![1]),
+            ("point_and_full_same_field", point_with_full_bound, vec![1]),
+            (
+                "multiple_bounds",
+                multiple_bounds,
+                (rows / 2 + 1..rows / 2 + 100).collect(),
+            ),
+            (
+                "point_and_90pct",
+                Predicate::and(vec![point, dense]),
+                vec![1],
+            ),
+            ("all_match", all, (0..rows).collect()),
+            ("bounded", bounded, (rows / 2 + 1..rows / 2 + 100).collect()),
+        ] {
+            for source in [&table, &no_index] {
+                for _ in 0..3 {
+                    assert_eq!(scan_ids(source, predicate.clone()).await, 
expected);
+                }
+            }
+            let sources = [&table, &no_index];
+            let mut samples = [Vec::new(), Vec::new()];
+            for round in 0..7 {
+                for i in [round % 2, 1 - round % 2] {
+                    let start = Instant::now();
+                    let actual = scan_ids(sources[i], predicate.clone()).await;
+                    let elapsed = start.elapsed().as_secs_f64() * 1000.0;
+                    assert_eq!(actual, expected);
+                    samples[i].push(elapsed);
+                }
+            }
+            for times in &mut samples {
+                times.sort_by(f64::total_cmp);
+            }
+            println!(
+                "TABLE_BENCH version={version} case={name} rows={} 
index_ms={:.3} scan_ms={:.3}",
+                expected.len(),
+                samples[0][3],
+                samples[1][3]
+            );
+        }
+    }
 }
 
 #[tokio::test]
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 1b7996ae..aa71e501 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
@@ -112,7 +112,12 @@ impl SortedGlobalIndexBuildBuilder<'_> {
                         write_options.compression_level,
                         write_options.bloom_filter_enabled,
                         cmp,
-                    );
+                    )
+                    .with_file_version(write_options.btree_file_version)
+                    .map_err(|error| Error::DataInvalid {
+                        message: "Invalid BTree file version".to_string(),
+                        source: Some(Box::new(error)),
+                    })?;
                     for (key, local_row_id) in &rows {
                         writer
                             .write(key.as_deref(), *local_row_id)
diff --git a/crates/paimon/src/table/sorted_global_index_options.rs 
b/crates/paimon/src/table/sorted_global_index_options.rs
index e7a16422..bea4a269 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_FILE_VERSION_OPTION: &str = "btree-index.file-version";
 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";
@@ -45,6 +46,7 @@ pub(crate) struct SortedIndexWriteOptions {
     pub(crate) compression_type: BlockCompressionType,
     pub(crate) compression_level: i32,
     pub(crate) bloom_filter_enabled: bool,
+    pub(crate) btree_file_version: u32,
 }
 
 impl SortedIndexWriteOptions {
@@ -76,6 +78,26 @@ impl SortedIndexWriteOptions {
             };
 
         Ok(Self {
+            btree_file_version: if index_type == BTREE_GLOBAL_INDEX_TYPE {
+                match options
+                    .get(BTREE_FILE_VERSION_OPTION)
+                    .map(|s| s.trim())
+                    .unwrap_or("1")
+                {
+                    "1" => 1,
+                    "2" => 2,
+                    raw => {
+                        return Err(Error::DataInvalid {
+                            message: format!(
+                                "Option '{BTREE_FILE_VERSION_OPTION}' must be 
1 or 2, got: {raw}"
+                            ),
+                            source: None,
+                        })
+                    }
+                }
+            } else {
+                1
+            },
             block_size: parse_block_size(options, block_size_option, 
default_block_size)?,
             compression_type: parse_compression(options, compression_option)?,
             compression_level: parse_compression_level(
@@ -185,6 +207,7 @@ mod tests {
                 compression_type: BlockCompressionType::None,
                 compression_level: 1,
                 bloom_filter_enabled: false,
+                btree_file_version: 1,
             }
         );
         for index_type in [BITMAP_GLOBAL_INDEX_TYPE, 
MULTIVALUE_GLOBAL_INDEX_TYPE] {
@@ -195,6 +218,7 @@ mod tests {
                     compression_type: BlockCompressionType::None,
                     compression_level: 1,
                     bloom_filter_enabled: false,
+                    btree_file_version: 1,
                 }
             );
         }
@@ -234,6 +258,7 @@ mod tests {
                     compression_type: BlockCompressionType::Lz4,
                     compression_level: 7,
                     bloom_filter_enabled: false,
+                    btree_file_version: 1,
                 }
             );
         }
@@ -253,6 +278,13 @@ mod tests {
     fn test_invalid_options() {
         for (index_type, option, value) in [
             (BTREE_GLOBAL_INDEX_TYPE, BTREE_BLOCK_SIZE_OPTION, "0"),
+            (BTREE_GLOBAL_INDEX_TYPE, BTREE_FILE_VERSION_OPTION, "0"),
+            (BTREE_GLOBAL_INDEX_TYPE, BTREE_FILE_VERSION_OPTION, "3"),
+            (
+                BTREE_GLOBAL_INDEX_TYPE,
+                BTREE_FILE_VERSION_OPTION,
+                "invalid",
+            ),
             (
                 BITMAP_GLOBAL_INDEX_TYPE,
                 BITMAP_DICTIONARY_BLOCK_SIZE_OPTION,
diff --git a/crates/paimon/testdata/btree/GenerateBTreeV2.java 
b/crates/paimon/testdata/btree/GenerateBTreeV2.java
new file mode 100644
index 00000000..dd250704
--- /dev/null
+++ b/crates/paimon/testdata/btree/GenerateBTreeV2.java
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+package org.apache.paimon.globalindex.btree;
+
+import org.apache.paimon.compression.BlockCompressionFactory;
+import org.apache.paimon.compression.BlockCompressionType;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.types.IntType;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+/** Generate compatibility fixtures with the Java production BTree V2 writer. 
*/
+public class GenerateBTreeV2 {
+    public static void main(String[] args) throws Exception {
+        for (BlockCompressionType codec : new BlockCompressionType[] {
+                BlockCompressionType.NONE, BlockCompressionType.LZ4}) {
+            final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            GlobalIndexFileWriter files = new GlobalIndexFileWriter() {
+                public String newFileName(String prefix) { return "fixture"; }
+                public PositionOutputStream newOutputStream(String name) {
+                    return new PositionOutputStream() {
+                        public long getPos() { return bytes.size(); }
+                        public void write(int b) { bytes.write(b); }
+                        public void write(byte[] b) { bytes.write(b, 0, 
b.length); }
+                        public void write(byte[] b, int off, int len) { 
bytes.write(b, off, len); }
+                        public void flush() {}
+                        public void close() {}
+                    };
+                }
+            };
+            BTreeIndexWriter writer = new BTreeIndexWriter(
+                    files, KeySerializer.create(new IntType()), 64, null,
+                    BlockCompressionFactory.create(codec), 2);
+            writer.write(0, 7);
+            for (long id : new long[] {2, 130, 65538, (1L << 32) + 5}) { 
writer.write(1, id); }
+            for (long id = 60000; id < 140000; id++) { writer.write(2, id); }
+            for (long id = (1L << 32) - 10; id < (1L << 32) + 10000; id++) { 
writer.write(3, id); }
+            writer.write(4, Long.MAX_VALUE);
+            // Repeated deltas make this block genuinely compressible with LZ4.
+            for (long id = 0; id < 4096; id++) { writer.write(5, (1L << 40) + 
id * 128); }
+            writer.write(null, 4);
+            writer.write(null, (1L << 32) + 20000);
+            writer.finish();
+            Files.write(Paths.get(args[0], "btree_v2_java_" + 
codec.name().toLowerCase() + ".bin"),
+                    bytes.toByteArray());
+        }
+    }
+}
diff --git a/crates/paimon/testdata/btree/README.md 
b/crates/paimon/testdata/btree/README.md
new file mode 100644
index 00000000..fbdcbaba
--- /dev/null
+++ b/crates/paimon/testdata/btree/README.md
@@ -0,0 +1,44 @@
+<!--
+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.
+-->
+
+# BTree V2 interoperability fixtures
+
+`btree_v2_java_none.bin` and `btree_v2_java_lz4.bin` are generated by the Java
+production `BTreeIndexWriter`, using `GenerateBTreeV2.java` in its package to
+select file version 2. They were generated against apache/paimon `b5bcf9babce`.
+
+Integer keys 0–5 exercise a singleton, a sparse delta list, dense Roaring runs,
+a run crossing the high-32-bit boundary, `Long.MAX_VALUE`, and a compressible
+delta list. Two null rows exercise the unchanged null-bitmap format. Blocks
+have a target size of 64 bytes. The LZ4 fixture contains both compressed and
+uncompressed blocks: the Java SST writer compresses only when it saves space.
+
+To regenerate, compile the helper with the Java `paimon-common` / `paimon-api`
+classes and their runtime dependencies on the classpath, then run:
+
+```sh
+javac -cp "$PAIMON_CLASSPATH" -d "$CLASSES_DIR" GenerateBTreeV2.java
+java -cp "$CLASSES_DIR:$PAIMON_CLASSPATH" \
+  org.apache.paimon.globalindex.btree.GenerateBTreeV2 .
+```
+
+Rust tests read the Java files and compare the Rust V2 writer's uncompressed
+output byte-for-byte with the Java fixture. A writer/reader round-trip alone
+would not catch incompatible posting tags, portable bitmap layout, or footer
+versions.
diff --git a/crates/paimon/testdata/btree/btree_v2_java_lz4.bin 
b/crates/paimon/testdata/btree/btree_v2_java_lz4.bin
new file mode 100644
index 00000000..2c7d92e5
Binary files /dev/null and b/crates/paimon/testdata/btree/btree_v2_java_lz4.bin 
differ
diff --git a/crates/paimon/testdata/btree/btree_v2_java_none.bin 
b/crates/paimon/testdata/btree/btree_v2_java_none.bin
new file mode 100644
index 00000000..d665b357
Binary files /dev/null and 
b/crates/paimon/testdata/btree/btree_v2_java_none.bin differ
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 28c292bb..7598ee53 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1143,6 +1143,23 @@ 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.
 
+BTree reads both Java-compatible V1 and V2 index files. Writing defaults to V1
+for compatibility with older readers. Set `btree-index.file-version=2` 
explicitly
+to use adaptive single-row, delta-list, and Roaring postings; every reader of
+the table must support V2 before enabling it. Existing index files do not need
+to be rebuilt for the reader to handle a mix of versions.
+
+BTree min/max metadata can also avoid index-file reads when every key matches,
+there are no nulls, and the file set's row counts prove complete coverage of
+the same source row-ID range. These all-matching files do not consume the
+fallback index-scan byte budget; partially matching files still do. The proof 
is
+applied separately to each conjunct, so an all-matching bound does not force a
+full posting-list scan alongside a selective equality on the same column.
+Multiple lower/upper bounds (including `BETWEEN`) on one column are tightened
+into one range query; contradictory bounds produce no indexed candidates
+without opening index files. Unindexed-tail handling and residual filtering
+continue to follow the selected global-index search mode.
+
 FM global indexes support character-string columns and exact byte-substring
 `contains`, `IS NULL`, and `IS NOT NULL` predicates. They use the
 Java-compatible partitioned V1 format. `sorted-index.records-per-range` bounds
@@ -2436,6 +2453,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.file-version` | `1` | BTree file version to write: `1` or `2`. 
V2 uses adaptive postings and requires V2-capable readers. |
 | `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). |
@@ -2453,7 +2471,7 @@ deletion vectors enabled.
 | `fm-index.read-cache-size` | `64mb` | Scan-scoped decoded FM block cache 
size. |
 | `fm-index.demand-page-size` | `512kb` | Target compressed-block read-ahead 
size for FM demand paging. |
 | `fm-index.locate-cost-ratio` | `0.001` | Maximum estimated locate work as a 
fraction of indexed text; denser matches fall back to the source scan. |
-| `btree-index.fallback-scan-max-size` | `256mb` | Maximum total size of 
selected BTree global-index files for fallback scans used by range/between and 
suffix/contains/complex LIKE predicates; `0` disables BTree fallback index 
scans. |
+| `btree-index.fallback-scan-max-size` | `256mb` | Maximum total size of BTree 
global-index files requiring fallback scans for range/between and 
suffix/contains/complex LIKE predicates; files proven to match completely by 
metadata need no scan and are excluded. `0` disables BTree fallback index 
scans. |
 | `bitmap-index.fallback-scan-max-size` | `256mb` | Maximum total size of 
selected bitmap global-index files for fallback scans used by range/between and 
suffix/contains/complex LIKE predicates; `0` disables bitmap fallback index 
scans. |
 | `global-index.search-mode` | `fast` | Global index coverage mode for reads: 
`fast`, `full`, or `detail`. |
 | `global-index.thread-num` | `32` | Number of concurrent global-index search 
tasks; must be greater than 0 and must not exceed the runtime's task limit. 
This does not limit Vindex file range reads. |

Reply via email to