This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 14482bdb0a [python] Read composite BTree indexes in data evolution 
scans (#10802)
14482bdb0a is described below

commit 14482bdb0a16f90b45b6c5bbfb84e19b679b4146
Author: XiaoHongbo <[email protected]>
AuthorDate: Tue Oct 27 09:30:20 2026 +0800

    [python] Read composite BTree indexes in data evolution scans (#10802)
---
 .../python/globalindex/btree/composite_btree.py    | 275 ++++++++++++
 .../python/globalindex/btree/composite_key.py      | 153 +++++++
 .../data_evolution_global_index_coverage.py        |  17 +-
 .../data_evolution_global_index_scanner.py         | 272 +++++++++++-
 .../execution/python/read/scanner/file_scanner.py  |  12 +-
 .../globalindex/global_index_evaluation.py         |   4 +-
 .../tests/composite_btree_fallback_test.py         | 463 ++++++++++++++++++++-
 paimon-python/pypaimon/tests/global_index_test.py  |   4 +-
 .../resources/btree/BTreeFixtureGenerator.java     | 121 ++++++
 .../pypaimon/tests/resources/btree/README.md       |   5 +
 .../resources/btree/java-v2-composite-ac.btree     | Bin 0 -> 232 bytes
 .../btree/java-v2-composite-ac.btree.meta          | Bin 0 -> 23 bytes
 .../resources/btree/java-v2-composite-int.btree    | Bin 0 -> 232 bytes
 .../btree/java-v2-composite-int.btree.meta         | Bin 0 -> 23 bytes
 .../resources/btree/java-v2-composite-keys.txt     |  18 +
 .../btree/java-v2-composite-reversed.btree         | Bin 0 -> 232 bytes
 .../btree/java-v2-composite-reversed.btree.meta    | Bin 0 -> 23 bytes
 17 files changed, 1327 insertions(+), 17 deletions(-)

diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/btree/composite_btree.py 
b/paimon-python/pypaimon/execution/python/globalindex/btree/composite_btree.py
new file mode 100644
index 0000000000..f369abbe17
--- /dev/null
+++ 
b/paimon-python/pypaimon/execution/python/globalindex/btree/composite_btree.py
@@ -0,0 +1,275 @@
+# 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.
+
+"""Read Java composite BTree keys with left-prefix predicate bounds."""
+
+from dataclasses import dataclass
+import datetime
+from decimal import Decimal
+import numbers
+import struct
+
+from pypaimon.execution.python.globalindex.btree.btree_index_reader import 
BTreeIndexReader
+from pypaimon.execution.python.globalindex.btree.btree_posting_list import 
add_row_ids
+from pypaimon.execution.python.globalindex.btree.composite_key import (
+    CompositeKeySerializer, compare_nullable,
+)
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+MAX_INTERVALS = 256
+_POINT = frozenset(('equal', 'in', 'isNull'))
+_RANGE = frozenset(('greaterThan', 'greaterOrEqual', 'lessThan',
+                    'lessOrEqual', 'between', 'isNotNull'))
+
+
+@dataclass(frozen=True)
+class Bound:
+    values: tuple
+    after: bool
+
+
+@dataclass
+class CompositePlan:
+    intervals: list
+    bound_columns: int
+    equal_columns: int
+    field_count: int
+    covered_predicates: tuple
+
+    @property
+    def point_lookup(self):
+        return self.equal_columns == self.field_count
+
+
+def _compare_values(left, right, comparators):
+    for index, (a, b) in enumerate(zip(left, right)):
+        result = compare_nullable(a, b, comparators[index])
+        if result:
+            return result
+    return 0
+
+
+def compare_bounds(left, right, comparators):
+    difference = _compare_values(left.values, right.values, comparators)
+    if difference:
+        return difference
+    if len(left.values) != len(right.values):
+        if len(left.values) < len(right.values):
+            return 1 if left.after else -1
+        return -1 if right.after else 1
+    return (left.after > right.after) - (left.after < right.after)
+
+
+def compare_key_bound(key, bound, comparators):
+    difference = _compare_values(key, bound.values, comparators)
+    return difference if difference else (-1 if bound.after else 1)
+
+
+def _conjuncts(predicate):
+    if predicate.method != 'and':
+        return [predicate]
+    result = []
+    for child in predicate.literals:
+        result.extend(_conjuncts(child))
+    return result
+
+
+def _as_leaf(predicate):
+    if predicate.method != 'or':
+        return predicate
+    children = predicate.literals or []
+    if not children or any(child.method != 'equal' or child.field != 
children[0].field
+                           for child in children):
+        return None
+    from pypaimon.common.predicate import Predicate
+    return Predicate('in', children[0].index, children[0].field,
+                     [child.literals[0] for child in children])
+
+
+def _matches(value, predicate, comparator):
+    method = predicate.method
+    values = predicate.literals or []
+    if method == 'isNull':
+        return value is None
+    if method == 'isNotNull':
+        return value is not None
+    if value is None:
+        return False
+    if method == 'in':
+        return any(literal is not None and comparator(value, literal) == 0
+                   for literal in values)
+    if any(literal is None for literal in values):
+        return False
+    if method == 'equal':
+        return comparator(value, values[0]) == 0
+    if method == 'between':
+        return (comparator(value, values[0]) >= 0
+                and comparator(value, values[1]) <= 0)
+    difference = comparator(value, values[0])
+    return {'greaterThan': difference > 0,
+            'greaterOrEqual': difference >= 0,
+            'lessThan': difference < 0,
+            'lessOrEqual': difference <= 0}[method]
+
+
+def _literal_supported(type_name, literal):
+    if type_name == 'BOOLEAN':
+        return isinstance(literal, bool)
+    if type_name in ('TINYINT', 'SMALLINT', 'INT', 'INTEGER', 'BIGINT'):
+        return isinstance(literal, numbers.Integral) and not 
isinstance(literal, bool)
+    if type_name.startswith(('CHAR', 'VARCHAR', 'STRING')):
+        return isinstance(literal, str)
+    if type_name in ('FLOAT', 'DOUBLE'):
+        # Java orders signed zeros separately, while Python row filtering does 
not.
+        return (isinstance(literal, numbers.Real)
+                and not isinstance(literal, bool) and literal != 0)
+    if type_name.startswith(('DECIMAL', 'NUMERIC')):
+        return isinstance(literal, (Decimal, numbers.Integral)) and not 
isinstance(literal, bool)
+    if type_name == 'DATE':
+        return ((isinstance(literal, datetime.date)
+                 and not isinstance(literal, datetime.datetime))
+                or isinstance(literal, numbers.Integral)
+                and not isinstance(literal, bool))
+    if type_name.startswith('TIME') and not type_name.startswith('TIMESTAMP'):
+        return (isinstance(literal, datetime.time)
+                and literal.microsecond % 1000 == 0
+                or isinstance(literal, numbers.Integral)
+                and not isinstance(literal, bool))
+    if type_name.startswith('TIMESTAMP'):
+        return (isinstance(literal, datetime.datetime)
+                and not getattr(literal, 'nanosecond', 0))
+    return False
+
+
+def plan_composite(fields, predicate):
+    """Return None when the index cannot safely bound this predicate."""
+    try:
+        serializer = CompositeKeySerializer(fields)
+    except ValueError:
+        return None
+    comparators = serializer._comparators
+    conjuncts = _conjuncts(predicate)
+    prefixes = [()]
+    equal_columns = 0
+    range_predicates = []
+    covered_predicates = []
+    for field, comparator, scalar in zip(
+            fields, comparators, serializer._serializers):
+        column = []
+        sources = []
+        for child in conjuncts:
+            leaf = _as_leaf(child)
+            if leaf is not None and leaf.field == field.name and (
+                    leaf.method in _POINT or leaf.method in _RANGE):
+                column.append(leaf)
+                sources.append(child)
+        if not column:
+            break
+        for leaf in column:
+            for literal in leaf.literals or []:
+                if literal is None:
+                    continue
+                if not _literal_supported(field.type.type.upper(), literal):
+                    return None
+                try:
+                    converted = scalar.deserialize(scalar.serialize(literal))
+                    if comparator(converted, literal) != 0:
+                        return None
+                except (ArithmeticError, ValueError, TypeError, struct.error):
+                    return None
+        point = [leaf for leaf in column if leaf.method in _POINT]
+        if not point:
+            range_predicates = column
+            covered_predicates.extend(sources)
+            break
+        selected = min(point, key=lambda leaf: len(leaf.literals or [None]))
+        candidates = ([None] if selected.method == 'isNull'
+                      else selected.literals or [])
+        values = []
+        for value in candidates:
+            if value is None and (selected.method != 'isNull' or not 
field.type.nullable):
+                continue
+            if all(_matches(value, leaf, comparator) for leaf in column):
+                if not any(compare_nullable(value, old, comparator) == 0 for 
old in values):
+                    values.append(value)
+        equal_columns += 1
+        covered_predicates.extend(sources)
+        if not values:
+            return CompositePlan([], equal_columns, equal_columns, len(fields),
+                                 tuple(covered_predicates))
+        if len(prefixes) * len(values) > MAX_INTERVALS:
+            return None
+        prefixes = [prefix + (value,) for prefix in prefixes for value in 
values]
+    bound_columns = equal_columns + bool(range_predicates)
+    if not bound_columns:
+        return None
+    intervals = []
+    for prefix in prefixes:
+        lower, upper = Bound(prefix, False), Bound(prefix, True)
+        if range_predicates:
+            lower = Bound(prefix + (None,), True)
+            empty = False
+            for leaf in range_predicates:
+                if leaf.method == 'isNotNull':
+                    continue
+                if None in (leaf.literals or []):
+                    empty = True
+                    break
+                if leaf.method in ('greaterThan', 'greaterOrEqual', 'between'):
+                    candidate = Bound(prefix + (leaf.literals[0],),
+                                      leaf.method == 'greaterThan')
+                    if compare_bounds(candidate, lower, comparators) > 0:
+                        lower = candidate
+                if leaf.method in ('lessThan', 'lessOrEqual', 'between'):
+                    candidate = Bound(prefix + (leaf.literals[-1],),
+                                      leaf.method != 'lessThan')
+                    if compare_bounds(candidate, upper, comparators) < 0:
+                        upper = candidate
+            if empty:
+                continue
+        if compare_bounds(lower, upper, comparators) < 0:
+            intervals.append((lower, upper))
+    return CompositePlan(intervals, bound_columns, equal_columns, len(fields),
+                         tuple(covered_predicates))
+
+
+class CompositeBTreeIndexReader(BTreeIndexReader):
+    def query(self, plan):
+        result = RoaringBitmap64()
+        if self.min_key is None:
+            return GlobalIndexResult.create(result, is_exact=False)
+        for lower, upper in plan.intervals:
+            if (compare_key_bound(self.max_key, lower, 
self.key_serializer._comparators) <= 0
+                    or compare_key_bound(self.min_key, upper,
+                                         self.key_serializer._comparators) >= 
0):
+                continue
+            if plan.point_lookup:
+                posting = self.reader.lookup(
+                    self.key_serializer.serialize(lower.values))
+                if posting is not None:
+                    add_row_ids(posting, self.footer.version, result)
+                continue
+            seek = lower.values + (None,) * (plan.field_count - 
len(lower.values))
+            for entry in 
self._entries_from(self.key_serializer.serialize(seek)):
+                key = self.key_serializer.deserialize(entry.key)
+                if compare_key_bound(key, upper, 
self.key_serializer._comparators) >= 0:
+                    break
+                if compare_key_bound(key, lower, 
self.key_serializer._comparators) > 0:
+                    add_row_ids(entry.value, self.footer.version, result)
+        return GlobalIndexResult.create(result, is_exact=False)
diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/btree/composite_key.py 
b/paimon-python/pypaimon/execution/python/globalindex/btree/composite_key.py
new file mode 100644
index 0000000000..6e41c20292
--- /dev/null
+++ b/paimon-python/pypaimon/execution/python/globalindex/btree/composite_key.py
@@ -0,0 +1,153 @@
+# 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 RowCompactedSerializer keys used by composite BTree indexes."""
+
+import math
+import struct
+
+from pypaimon.data.timestamp import Timestamp
+from pypaimon.execution.python.globalindex.key_serializer import (
+    KeySerializer, create_serializer, _read_var_len_int, _write_var_len_int,
+    _parse_precision,
+)
+
+
+class CompositeKeySerializer(KeySerializer):
+    def __init__(self, fields):
+        self._types = [field.type.type.upper() for field in fields]
+        self._serializers = [create_serializer(field.type) for field in fields]
+        self._comparators = [
+            _java_float_compare if type_name in ('FLOAT', 'DOUBLE')
+            else serializer.create_comparator()
+            for type_name, serializer in zip(self._types, self._serializers)
+        ]
+        self._header_size = (len(fields) + 15) // 8
+
+    def serialize(self, key):
+        if len(key) != len(self._serializers):
+            raise ValueError("Wrong composite key arity")
+        header = bytearray(self._header_size)
+        values = bytearray()
+        for index, (value, serializer, type_name) in enumerate(
+                zip(key, self._serializers, self._types)):
+            if value is None:
+                header[(index + 8) // 8] |= 1 << ((index + 8) % 8)
+                continue
+            if type_name == 'FLOAT' and math.isnan(float(value)):
+                encoded = struct.pack('<I', 0x7fc00000)
+            elif type_name == 'DOUBLE' and math.isnan(float(value)):
+                encoded = struct.pack('<Q', 0x7ff8000000000000)
+            else:
+                encoded = serializer.serialize(value)
+            if _variable_length(type_name):
+                values.extend(_write_var_len_int(len(encoded)))
+            values.extend(encoded)
+        return bytes(header + values)
+
+    def deserialize(self, data):
+        if len(data) < self._header_size:
+            raise ValueError("Truncated composite key")
+        position = self._header_size
+        result = []
+        for index, (serializer, type_name) in enumerate(
+                zip(self._serializers, self._types)):
+            if data[(index + 8) // 8] & (1 << ((index + 8) % 8)):
+                result.append(None)
+                continue
+            if _variable_length(type_name):
+                length, position = _read_var_len_int(data, position)
+            else:
+                length = _fixed_length(type_name)
+                if type_name.startswith('TIMESTAMP') and _parse_precision(
+                        type_name, 6) > 3:
+                    _, end = _read_var_len_int(data, position + 8)
+                    length = end - position
+            end = position + length
+            if end > len(data):
+                raise ValueError("Truncated composite key value")
+            encoded = data[position:end]
+            if type_name.startswith('TIMESTAMP'):
+                milliseconds = struct.unpack('<q', encoded[:8])[0]
+                nanos = (_read_var_len_int(encoded, 8)[0]
+                         if len(encoded) > 8 else 0)
+                result.append(Timestamp.from_epoch_millis(milliseconds, nanos))
+            elif type_name == 'DATE' or (type_name.startswith('TIME')
+                                         and not 
type_name.startswith('TIMESTAMP')):
+                result.append(struct.unpack('<i', encoded)[0])
+            else:
+                result.append(serializer.deserialize(encoded))
+            position = end
+        if position != len(data):
+            raise ValueError("Trailing composite key bytes")
+        return tuple(result)
+
+    def create_comparator(self):
+        def compare(left, right):
+            for a, b, comparator in zip(left, right, self._comparators):
+                difference = compare_nullable(a, b, comparator)
+                if difference:
+                    return difference
+            return 0
+        return compare
+
+
+def _variable_length(type_name):
+    return (type_name.startswith(('CHAR', 'VARCHAR', 'STRING'))
+            or type_name.startswith(('DECIMAL', 'NUMERIC'))
+            and _decimal_precision(type_name) > 18)
+
+
+def _decimal_precision(type_name):
+    from pypaimon.execution.python.globalindex.key_serializer import 
_parse_decimal_params
+    return _parse_decimal_params(type_name)[0]
+
+
+def _fixed_length(type_name):
+    if type_name in ('BOOLEAN', 'TINYINT'):
+        return 1
+    if type_name == 'SMALLINT':
+        return 2
+    if type_name in ('INT', 'INTEGER', 'DATE', 'FLOAT') or (
+            type_name.startswith('TIME') and not 
type_name.startswith('TIMESTAMP')):
+        return 4
+    if type_name in ('BIGINT', 'DOUBLE') or type_name.startswith(('DECIMAL', 
'NUMERIC')):
+        return 8
+    if type_name.startswith('TIMESTAMP'):
+        # The caller handles the optional trailing varint separately.
+        return 8
+    raise ValueError("Unsupported composite key type: %s" % type_name)
+
+
+def compare_nullable(left, right, comparator):
+    if left is None:
+        return 0 if right is None else -1
+    if right is None:
+        return 1
+    return comparator(left, right)
+
+
+def _java_float_compare(left, right):
+    left, right = float(left), float(right)
+    if math.isnan(left):
+        return 0 if math.isnan(right) else 1
+    if math.isnan(right):
+        return -1
+    if left == right == 0:
+        return (math.copysign(1, left) > math.copysign(1, right)) - (
+            math.copysign(1, left) < math.copysign(1, right))
+    return (left > right) - (left < right)
diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_coverage.py
 
b/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_coverage.py
index 19aa1c251f..fe051153cf 100644
--- 
a/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_coverage.py
+++ 
b/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_coverage.py
@@ -45,11 +45,12 @@ class DataEvolutionGlobalIndexCoverage:
             meta = index_file.global_index_meta
             if meta is None:
                 continue
+            if getattr(index_file, 'index_type', None) == 'btree' and 
meta.extra_field_ids:
+                continue
             row_range = Range(meta.row_range_start, meta.row_range_end)
             self._add_coverage(meta.index_field_id, row_range)
-            if meta.extra_field_ids is not None:
-                for extra_field_id in meta.extra_field_ids:
-                    self._add_coverage(extra_field_id, row_range)
+            for field_id in meta.extra_field_ids or []:
+                self._add_coverage(field_id, row_range)
 
     def unindexed_ranges(
         self,
@@ -70,6 +71,12 @@ class DataEvolutionGlobalIndexCoverage:
                     field_ids.add(field.id)
         return self._unindexed_ranges(field_ids, search_mode)
 
+    def indexed_ranges(self, field_ids):
+        return self._indexed_ranges(field_ids)
+
+    def unindexed_ranges_from_indexed(self, indexed_ranges, search_mode=None):
+        return self._unindexed_ranges((), search_mode, indexed_ranges)
+
     def _add_coverage(self, field_id: int, row_range: Range) -> None:
         self._coverage_by_field.setdefault(field_id, []).append(row_range)
 
@@ -89,6 +96,7 @@ class DataEvolutionGlobalIndexCoverage:
         self,
         field_ids: Collection[int],
         search_mode: Optional[GlobalIndexSearchMode] = None,
+        indexed_ranges=None,
     ) -> List[Range]:
         if search_mode is None:
             search_mode = _scalar_index_search_mode(self._table)
@@ -106,7 +114,8 @@ class DataEvolutionGlobalIndexCoverage:
             data_ranges = [Range(0, next_row_id - 1)]
 
         indexed_ranges = Range.sort_and_merge_overlap(
-            self._indexed_ranges(field_ids), True)
+            self._indexed_ranges(field_ids) if indexed_ranges is None
+            else indexed_ranges, True)
         unindexed = []
         for data_range in Range.sort_and_merge_overlap(data_ranges, True):
             unindexed.extend(data_range.exclude(indexed_ranges))
diff --git 
a/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_scanner.py
 
b/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_scanner.py
index 6e9d562332..eb8627f5d8 100644
--- 
a/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_scanner.py
+++ 
b/paimon-python/pypaimon/execution/python/globalindex/data_evolution_global_index_scanner.py
@@ -18,6 +18,7 @@
 """Scanner for shard-based global indexes on data-evolution tables."""
 
 from concurrent.futures import ThreadPoolExecutor
+import threading
 from typing import Collection, List, Optional
 
 from pypaimon.execution.python.globalindex.global_index_evaluator import (
@@ -39,6 +40,10 @@ from pypaimon.utils.range import Range
 _SUPPORTED_SCALAR_INDEX_TYPES = frozenset(('btree', 'bitmap'))
 
 
+class CompositeIndexReadError(RuntimeError):
+    """A selected composite index is unreadable; falling back may hide 
corruption."""
+
+
 class DataEvolutionGlobalIndexScanner:
     """Scanner for shard-based global indexes."""
 
@@ -53,6 +58,7 @@ class DataEvolutionGlobalIndexScanner:
         table=None,
         snapshot=None,
         partition_filter=None,
+        composite_files=None,
     ):
         index_files = _supported_scalar_index_files(index_files)
         self._options = options or CoreOptions(Options.from_none())
@@ -60,6 +66,12 @@ class DataEvolutionGlobalIndexScanner:
             max_workers=thread_num or 32
         )
         self._fields = fields
+        self._file_io = file_io
+        self._index_path = index_path
+        self._composite_groups = _group_composite_files(composite_files or [])
+        self._composite_readers = {}
+        self._composite_reader_lock = threading.Lock()
+        self._composite_reader_locks = {}
         self._coverage = (
             DataEvolutionGlobalIndexCoverage(table, snapshot, 
partition_filter, index_files)
             if table is not None
@@ -139,7 +151,7 @@ class DataEvolutionGlobalIndexScanner:
 
     @staticmethod
     def create(table, index_files=None, partition_filter=None, predicate=None,
-               snapshot=None) -> Optional['DataEvolutionGlobalIndexScanner']:
+               snapshot=None, allow_composite=False) -> 
Optional['DataEvolutionGlobalIndexScanner']:
         """Create a DataEvolutionGlobalIndexScanner.
 
         Can be called in two ways:
@@ -152,15 +164,19 @@ class DataEvolutionGlobalIndexScanner:
         from pypaimon.index.index_file_handler import IndexFileHandler
 
         if index_files is not None:
-            index_files = _supported_scalar_index_files(index_files)
-            if len(index_files) == 0:
+            index_files = list(index_files)
+            scalar_files = _supported_scalar_index_files(index_files)
+            composite_files = [file for file in index_files
+                               if allow_composite and 
_is_composite_btree(file)]
+            if not scalar_files and not composite_files:
                 return None
             core_options = _core_options(table)
             return DataEvolutionGlobalIndexScanner(
                 fields=table.fields,
                 file_io=table.file_io,
                 
index_path=table.path_factory().global_index_path_factory().index_path(),
-                index_files=index_files,
+                index_files=scalar_files,
+                composite_files=composite_files,
                 thread_num=core_options.global_index_thread_num(),
                 options=core_options,
                 table=table,
@@ -180,7 +196,8 @@ class DataEvolutionGlobalIndexScanner:
             if partition_filter is not None:
                 if not partition_filter.test(entry.partition):
                     return False
-            if not is_supported_scalar_index(entry.index_file):
+            if not (is_supported_scalar_index(entry.index_file)
+                    or allow_composite and 
_is_composite_btree(entry.index_file)):
                 return False
             global_index_meta = entry.index_file.global_index_meta
             if global_index_meta is None:
@@ -208,6 +225,8 @@ class DataEvolutionGlobalIndexScanner:
             file_io=table.file_io,
             
index_path=table.path_factory().global_index_path_factory().index_path(),
             index_files=scanned_index_files,
+            composite_files=[file for file in scanned_index_files
+                             if allow_composite and _is_composite_btree(file)],
             thread_num=core_options.global_index_thread_num(),
             options=core_options,
             table=table,
@@ -217,29 +236,238 @@ class DataEvolutionGlobalIndexScanner:
 
     def scan(self, predicate: Optional[Predicate]) -> 
Optional[GlobalIndexResult]:
         """Scan the global index with the given predicate."""
-        return self._evaluator.evaluate(predicate)
+        evaluation = self.scan_with_coverage(predicate)
+        return evaluation.result if evaluation is not None else None
 
     def scan_with_coverage(
         self, predicate: Optional[Predicate]
     ) -> Optional[GlobalIndexEvaluation]:
+        if not self._composite_groups or predicate is None:
+            return self._evaluator.evaluate_with_contributing_fields(predicate)
+        return self._evaluate(predicate)
+
+    def _evaluate(self, predicate):
+        from pypaimon.execution.python.globalindex.btree.composite_btree 
import (
+            _conjuncts,
+            plan_composite,
+        )
+
+        fields_by_id = {field.id: field for field in self._fields}
+        candidates = []
+        for field_ids, files in self._composite_groups.items():
+            if any(field_id not in fields_by_id for field_id in field_ids):
+                continue
+            fields = [fields_by_id[field_id] for field_id in field_ids]
+            plan = plan_composite(fields, predicate)
+            if plan is None:
+                continue
+            if plan.bound_columns == 1 and self._coverage is not None:
+                composite_ranges = Range.sort_and_merge_overlap(list(files), 
True)
+                scalar_ranges = self._coverage.indexed_ranges([field_ids[0]])
+                if Range.and_(composite_ranges, scalar_ranges) == 
composite_ranges:
+                    continue
+            try:
+                selected = self._select_composite_files(files, fields, plan)
+            except Exception as error:
+                raise CompositeIndexReadError("Cannot read composite index 
metadata") from error
+            if selected is None:
+                continue
+            candidates.append((plan.bound_columns, plan.equal_columns,
+                               -len(field_ids), field_ids, fields, files, 
selected, plan))
+        if candidates:
+            _, _, _, field_ids, fields, files, selected, plan = max(
+                candidates, key=lambda candidate: candidate[:3])
+            if predicate.method == 'and':
+                key_fields = {field.name for field in fields}
+                remaining = []
+                for child in _conjuncts(predicate):
+                    if any(child is used for used in plan.covered_predicates):
+                        continue
+                    referenced_fields = _get_all_fields(child)
+                    # Key residuals stay in the final row filter.
+                    if referenced_fields and referenced_fields <= key_fields:
+                        continue
+                    remaining.append(child)
+                if remaining:
+                    rest = (remaining[0] if len(remaining) == 1
+                            else Predicate('and', None, None, remaining))
+                    if not self._may_use_composite(rest):
+                        rest_future = self._evaluator._visit_async(rest)
+                        try:
+                            composite = self._read_composite(
+                                field_ids, fields, files, selected, plan)
+                        except BaseException:
+                            try:
+                                rest_future.result()
+                            except BaseException:
+                                pass
+                            raise
+                        return self._combine_evaluations(
+                            [composite, rest_future.result()], 'and')
+                    composite = self._read_composite(
+                        field_ids, fields, files, selected, plan)
+                    return self._combine_evaluations(
+                        [composite, self._evaluate(rest)], 'and')
+            return self._read_composite(field_ids, fields, files, selected, 
plan)
+
+        if predicate.method in ('and', 'or'):
+            scalar_children = []
+            composite_children = []
+            for child in predicate.literals:
+                if self._may_use_composite(child):
+                    composite_children.append(child)
+                else:
+                    scalar_children.append(child)
+            if not composite_children:
+                return 
self._evaluator.evaluate_with_contributing_fields(predicate)
+            scalar_future = None
+            if scalar_children:
+                scalar = (scalar_children[0] if len(scalar_children) == 1
+                          else Predicate(predicate.method, None, None, 
scalar_children))
+                scalar_future = self._evaluator._visit_async(scalar)
+            evaluations = [self._evaluate(child) for child in 
composite_children]
+            if scalar_future is not None:
+                evaluations.append(scalar_future.result())
+            return self._combine_evaluations(evaluations, predicate.method)
         return self._evaluator.evaluate_with_contributing_fields(predicate)
 
+    def _may_use_composite(self, predicate):
+        from pypaimon.execution.python.globalindex.btree.composite_btree 
import plan_composite
+
+        fields_by_id = {field.id: field for field in self._fields}
+        predicate_fields = _get_all_fields(predicate)
+        for field_ids in self._composite_groups:
+            if (all(field_id in fields_by_id for field_id in field_ids)
+                    and fields_by_id[field_ids[0]].name in predicate_fields):
+                fields = [fields_by_id[field_id] for field_id in field_ids]
+                if plan_composite(fields, predicate) is not None:
+                    return True
+        return (predicate.method in ('and', 'or')
+                and any(self._may_use_composite(child) for child in 
predicate.literals))
+
+    def _combine_evaluations(self, evaluations, method):
+        if method == 'or' and any(item is None for item in evaluations):
+            return None
+        present = [item for item in evaluations if item is not None]
+        if not present:
+            return None
+        result = present[0].result
+        ranges = self._evaluation_ranges(present[0])
+        field_ids = set(present[0].contributing_field_ids)
+        for item in present[1:]:
+            result = (result.or_(item.result) if method == 'or'
+                      else result.and_(item.result))
+            ranges = Range.and_(ranges, self._evaluation_ranges(item))
+            field_ids.update(item.contributing_field_ids)
+        if len(present) != len(evaluations):
+            result = GlobalIndexResult.create(result.results(), is_exact=False)
+        return GlobalIndexEvaluation(result, frozenset(field_ids), 
tuple(ranges))
+
+    def _evaluation_ranges(self, evaluation):
+        if evaluation.indexed_ranges is not None:
+            return list(evaluation.indexed_ranges)
+        if self._coverage is None:
+            return []
+        return self._coverage.indexed_ranges(evaluation.contributing_field_ids)
+
+    def _select_composite_files(self, files, fields, plan):
+        from pypaimon.execution.python.globalindex.btree.composite_btree 
import (
+            compare_key_bound,
+        )
+        from pypaimon.execution.python.globalindex.btree.composite_key import (
+            CompositeKeySerializer,
+        )
+        from pypaimon.globalindex.sorted_index_file_meta import 
SortedIndexFileMeta
+
+        if not plan.intervals:
+            return []
+        serializer = CompositeKeySerializer(fields)
+        selected = []
+        budget = self._options.btree_index_fallback_scan_max_size()
+        for row_range, metas in files.items():
+            shard = []
+            for meta in metas:
+                if meta.metadata is None:
+                    return None
+                index_meta = SortedIndexFileMeta.deserialize(meta.metadata)
+                if index_meta.first_key is not None and plan.intervals:
+                    first = serializer.deserialize(index_meta.first_key)
+                    last = serializer.deserialize(index_meta.last_key)
+                    if not any(compare_key_bound(last, lower, 
serializer._comparators) > 0
+                               and compare_key_bound(first, upper,
+                                                     serializer._comparators) 
< 0
+                               for lower, upper in plan.intervals):
+                        continue
+                if plan.intervals:
+                    shard.append((row_range, meta))
+            if not plan.point_lookup and sum(meta.file_size for _, meta in 
shard) > budget:
+                return None
+            selected.extend(shard)
+        return selected
+
+    def _read_composite(self, field_ids, fields, files, selected, plan):
+        from pypaimon.execution.python.globalindex.btree.composite_btree 
import (
+            CompositeBTreeIndexReader,
+        )
+        from pypaimon.execution.python.globalindex.btree.composite_key import (
+            CompositeKeySerializer,
+        )
+
+        serializer = CompositeKeySerializer(fields)
+        futures = []
+        for row_range, meta in selected:
+            def read(range_=row_range, io_meta=meta):
+                key = (field_ids, io_meta.external_path or io_meta.file_name)
+                with self._composite_reader_lock:
+                    reader = self._composite_readers.get(key)
+                    reader_lock = (self._composite_reader_locks.setdefault(
+                        key, threading.Lock()) if reader is None else None)
+                if reader is None:
+                    with reader_lock:
+                        with self._composite_reader_lock:
+                            reader = self._composite_readers.get(key)
+                        if reader is None:
+                            reader = CompositeBTreeIndexReader(
+                                serializer, self._file_io, self._index_path, 
io_meta)
+                            with self._composite_reader_lock:
+                                self._composite_readers[key] = reader
+                return reader.query(plan).offset(range_.from_)
+            futures.append(self._executor.submit(read))
+        result = GlobalIndexResult.create_empty()
+        failure = None
+        for future in futures:
+            try:
+                result = result.or_(future.result())
+            except Exception as error:
+                if failure is None:
+                    failure = error
+        if failure is not None:
+            raise CompositeIndexReadError("Cannot read composite BTree file") 
from failure
+        result = GlobalIndexResult.create(result.results(), is_exact=False)
+        return GlobalIndexEvaluation(
+            result, frozenset(field_ids[:plan.bound_columns]),
+            tuple(Range.sort_and_merge_overlap(list(files), True)))
+
     def unindexed_rows(self, predicate: Optional[Predicate],
                        search_mode=None,
-                       contributing_field_ids=None) -> GlobalIndexResult:
+                       contributing_field_ids=None, indexed_ranges=None) -> 
GlobalIndexResult:
         """Return coarse row ids not covered by global indexes."""
         return GlobalIndexResult.from_ranges(self.unindexed_ranges(
             predicate,
             search_mode=search_mode,
             contributing_field_ids=contributing_field_ids,
+            indexed_ranges=indexed_ranges,
         ))
 
     def unindexed_ranges(self, predicate: Optional[Predicate],
                          search_mode=None,
-                         contributing_field_ids=None) -> List[Range]:
+                         contributing_field_ids=None, indexed_ranges=None) -> 
List[Range]:
         """Return row ranges not covered by global indexes."""
         if self._coverage is None:
             return []
+        if indexed_ranges is not None:
+            return self._coverage.unindexed_ranges_from_indexed(
+                indexed_ranges, search_mode=search_mode)
         if contributing_field_ids is not None:
             return self._coverage.unindexed_ranges(
                 contributing_field_ids, search_mode=search_mode)
@@ -249,6 +477,8 @@ class DataEvolutionGlobalIndexScanner:
     def close(self):
         """Close the scanner and release resources."""
         self._evaluator.close()
+        for reader in self._composite_readers.values():
+            reader.close()
         self._executor.shutdown(wait=False)
 
     def __enter__(self) -> 'DataEvolutionGlobalIndexScanner':
@@ -311,6 +541,32 @@ def _supported_scalar_index_files(index_files):
             if is_supported_scalar_index(index_file)]
 
 
+def _is_composite_btree(index_file):
+    meta = index_file.global_index_meta
+    return (index_file.index_type == 'btree' and meta is not None
+            and bool(meta.extra_field_ids)
+            and len(set((meta.index_field_id,) + tuple(meta.extra_field_ids)))
+            == 1 + len(meta.extra_field_ids))
+
+
+def _group_composite_files(index_files):
+    groups = {}
+    for index_file in index_files:
+        if not _is_composite_btree(index_file):
+            continue
+        meta = index_file.global_index_meta
+        field_ids = (meta.index_field_id,) + tuple(meta.extra_field_ids)
+        row_range = Range(meta.row_range_start, meta.row_range_end)
+        io_meta = GlobalIndexIOMeta(
+            file_name=index_file.file_name,
+            file_size=index_file.file_size,
+            metadata=meta.index_meta,
+            external_path=index_file.external_path,
+        )
+        groups.setdefault(field_ids, {}).setdefault(row_range, 
[]).append(io_meta)
+    return groups
+
+
 def _core_options(table):
     options = getattr(table, "options", None)
     if options is None:
diff --git 
a/paimon-python/pypaimon/execution/python/read/scanner/file_scanner.py 
b/paimon-python/pypaimon/execution/python/read/scanner/file_scanner.py
index 43a1493d5f..b3caa043ca 100755
--- a/paimon-python/pypaimon/execution/python/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/execution/python/read/scanner/file_scanner.py
@@ -562,6 +562,7 @@ class FileScanner:
                 partition_filter=self.partition_key_predicate,
                 predicate=predicate,
                 snapshot=snapshot,
+                allow_composite=True,
             )
             if scanner is None:
                 return None
@@ -570,6 +571,9 @@ class FileScanner:
                 if evaluation is None:
                     return None
                 scalar_mode = self.table.options.scalar_index_search_mode()
+                coverage_args = {}
+                if evaluation.indexed_ranges is not None:
+                    coverage_args["indexed_ranges"] = evaluation.indexed_ranges
                 return _GlobalIndexPlanningResult(
                     evaluation.result,
                     scanner.unindexed_ranges(
@@ -577,9 +581,15 @@ class FileScanner:
                         search_mode=scalar_mode,
                         contributing_field_ids=(
                             evaluation.contributing_field_ids),
+                        **coverage_args,
                     ),
                 )
-        except Exception:
+        except Exception as error:
+            from 
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner 
import (
+                CompositeIndexReadError,
+            )
+            if isinstance(error, CompositeIndexReadError):
+                raise
             return None
 
     def read_manifest_entries(self, manifest_files: List[ManifestFileMeta],
diff --git a/paimon-python/pypaimon/globalindex/global_index_evaluation.py 
b/paimon-python/pypaimon/globalindex/global_index_evaluation.py
index 92a33ff71b..bb4a4611a0 100644
--- a/paimon-python/pypaimon/globalindex/global_index_evaluation.py
+++ b/paimon-python/pypaimon/globalindex/global_index_evaluation.py
@@ -17,9 +17,10 @@
 
 """Global index evaluation result shared with scan planning."""
 
-from typing import FrozenSet, NamedTuple
+from typing import FrozenSet, NamedTuple, Optional, Tuple
 
 from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.utils.range import Range
 
 
 class GlobalIndexEvaluation(NamedTuple):
@@ -27,3 +28,4 @@ class GlobalIndexEvaluation(NamedTuple):
 
     result: GlobalIndexResult
     contributing_field_ids: FrozenSet[int]
+    indexed_ranges: Optional[Tuple[Range, ...]] = None
diff --git a/paimon-python/pypaimon/tests/composite_btree_fallback_test.py 
b/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
index 2f887d6522..4cbce647c6 100644
--- a/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
+++ b/paimon-python/pypaimon/tests/composite_btree_fallback_test.py
@@ -16,22 +16,42 @@
 # under the License.
 
 import unittest
+import shutil
+import tempfile
+import threading
+from datetime import time
 from dataclasses import replace
+from decimal import Decimal
+from pathlib import Path
 from unittest.mock import patch
 
 import pyarrow as pa
 import pytest
 
 from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.data.timestamp import Timestamp
 from pypaimon.execution.python.globalindex.data_evolution_global_index_scanner 
import (
+    CompositeIndexReadError,
     DataEvolutionGlobalIndexScanner,
     is_supported_scalar_index,
 )
+from pypaimon.execution.python.globalindex.btree.composite_btree import (
+    CompositeBTreeIndexReader,
+    plan_composite,
+)
+from pypaimon.execution.python.globalindex.btree.btree_index_writer import 
BTreeIndexWriter
+from pypaimon.execution.python.globalindex.btree.btree_index_reader import 
BTreeIndexReader
+from pypaimon.execution.python.globalindex.btree.composite_key import (
+    CompositeKeySerializer,
+)
 from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.execution.python.globalindex.key_serializer import 
create_serializer
 from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
 from pypaimon.index.index_file_handler import IndexFileHandler
 from pypaimon.index.index_file_meta import IndexFileMeta
 from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.schema.data_types import AtomicType, DataField
 from pypaimon.execution.python.read.scanner.file_scanner import (FileScanner)
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.tests.data_evolution_test_helpers import (
@@ -53,6 +73,17 @@ def _composite_file():
     )
 
 
+def _java_composite_file(name='java-v2-composite-int.btree', field_ids=(0, 1)):
+    path = Path(__file__).parent / 'resources/btree' / name
+    return IndexFileMeta(
+        index_type='btree', file_name=path.name,
+        external_path=str(path), file_size=path.stat().st_size, row_count=7,
+        global_index_meta=GlobalIndexMeta(
+            0, 6, field_ids[0], list(field_ids[1:]),
+            Path(str(path) + '.meta').read_bytes()),
+    )
+
+
 class CompositeBTreeFallbackTest(
         BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
 
@@ -120,7 +151,10 @@ class CompositeBTreeFallbackTest(
         table = self._table_with_partial_index()
         snapshot = table.snapshot_manager().get_latest_snapshot()
         scalar_entries = IndexFileHandler(table).scan(snapshot)
-        composite_entry = IndexManifestEntry(0, GenericRow([], []), 0, 
_composite_file())
+        unsupported = _composite_file()
+        unsupported = replace(unsupported, global_index_meta=replace(
+            unsupported.global_index_meta, extra_field_ids=[99]))
+        composite_entry = IndexManifestEntry(0, GenericRow([], []), 0, 
unsupported)
         b = table.new_read_builder().new_predicate_builder()
         cases = [
             (b.equal('a', 2), True, [2]),
@@ -180,3 +214,430 @@ class CompositeBTreeFallbackTest(
         builder = table.new_read_builder().with_filter(predicate)
         rows = builder.new_read().to_arrow(builder.new_scan().plan().splits())
         return sorted(rows.column('a').to_pylist(), key=lambda value: (value 
is not None, value))
+
+    @pytest.mark.python_plan
+    def test_java_composite_index_and_partial_coverage(self):
+        schema = pa.schema([('a', pa.int32()), ('b', pa.int32()), ('c', 
pa.int32())])
+        table = self._create_table(pa_schema=schema)
+        self._write_arrow(table, pa.table({
+            'a': [None, None, 1, 1, 1, 1, 2],
+            'b': [None, 2, None, 1, 2, 2, 1],
+            'c': [None, 2, None, 1, 2, 2, 1],
+        }, schema=schema))
+        self._write_arrow(table, pa.table({
+            'a': [1, 2], 'b': [2, 1], 'c': [2, 1],
+        }, schema=schema))
+        entries = [IndexManifestEntry(0, GenericRow([], []), 0, file) for file 
in (
+            _java_composite_file(),
+            _java_composite_file('java-v2-composite-ac.btree', (0, 2)),
+            _java_composite_file('java-v2-composite-reversed.btree', (1, 0)),
+        )]
+        table.create_global_index('a')
+        entries.extend(IndexFileHandler(table).scan(
+            table.snapshot_manager().get_latest_snapshot()))
+        builder = table.new_read_builder().new_predicate_builder()
+        cases = [
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.equal('b', 2)]),
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.equal('c', 2)]),
+            builder.equal('a', 1),
+            builder.is_null('a'),
+            PredicateBuilder.and_predicates([
+                builder.is_null('a'), builder.is_null('b')]),
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.greater_than('b', 1)]),
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.greater_than('b', 0),
+                builder.less_than('b', 2)]),
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.equal('b', 1),
+                builder.equal('c', 2)]),
+            builder.is_in('a', [1, 2]),
+            PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.is_in('b', [2, None])]),
+            builder.greater_than('a', 1),
+            builder.is_not_null('b'),
+            builder.less_than('b', 1.5),
+            builder.is_in('b', list(range(257))),
+            builder.equal('a', 99),
+            builder.equal('b', 1),
+            builder.not_equal('a', 1),
+            PredicateBuilder.or_predicates([
+                builder.equal('a', 1), builder.equal('a', 2)]),
+            PredicateBuilder.or_predicates([
+                PredicateBuilder.and_predicates([
+                    builder.equal('a', 1), builder.equal('b', 2)]),
+                builder.is_null('a')]),
+            PredicateBuilder.or_predicates([
+                builder.equal('b', 1), builder.equal('c', 2)]),
+        ]
+        reads = []
+        original = CompositeBTreeIndexReader.query
+
+        def observed(reader, plan):
+            reads.append(reader.io_meta.file_name)
+            return original(reader, plan)
+
+        def scan_indexes(handler, snapshot, entry_filter=None):
+            return [entry for entry in entries
+                    if entry_filter is None or entry_filter(entry)]
+
+        with patch.object(IndexFileHandler, 'scan', scan_indexes), 
patch.object(
+                CompositeBTreeIndexReader, 'query', observed):
+            for predicate in cases:
+                baseline = self._rows(
+                    table.copy({'global-index.enabled': 'false'}), predicate)
+                for mode in ('full', 'detail'):
+                    with self.subTest(predicate=predicate, mode=mode):
+                        actual = self._rows(
+                            table.copy({'scalar-index.search-mode': mode}), 
predicate)
+                        self.assertEqual(baseline, actual)
+            exact = PredicateBuilder.and_predicates([
+                builder.equal('a', 1), builder.equal('b', 2)])
+            with DataEvolutionGlobalIndexScanner.create(
+                    table, predicate=exact, allow_composite=True) as scanner:
+                evaluation = scanner.scan_with_coverage(exact)
+                self.assertEqual([4, 5], evaluation.result.results().to_list())
+                self.assertEqual((Range(0, 6),), evaluation.indexed_ranges)
+                self.assertEqual([Range(7, 8)], scanner.unindexed_ranges(
+                    exact, search_mode=table.copy({
+                        'scalar-index.search-mode': 'full'
+                    }).options.scalar_index_search_mode(),
+                    indexed_ranges=evaluation.indexed_ranges))
+            fast = self._rows(table.copy({'scalar-index.search-mode': 
'fast'}), exact)
+            self.assertEqual(2, len(fast))
+            read_count = len(reads)
+            limited = table.copy({
+                'scalar-index.search-mode': 'full',
+                'btree-index.fallback-scan-max-size': '1B',
+            })
+            self.assertEqual(
+                self._rows(table.copy({'global-index.enabled': 'false'}),
+                           builder.equal('b', 1)),
+                self._rows(limited, builder.equal('b', 1)))
+            self.assertEqual(read_count, len(reads))
+        self.assertTrue(reads, 'composite SST reader was not used')
+        self.assertIn('java-v2-composite-int.btree', reads)
+        self.assertIn('java-v2-composite-ac.btree', reads)
+        self.assertIn('java-v2-composite-reversed.btree', reads)
+
+    @pytest.mark.python_plan
+    def test_explicit_composite_files_and_remaining_scalar_predicate(self):
+        schema = pa.schema([('a', pa.int32()), ('b', pa.int32()), ('c', 
pa.int32())])
+        table = self._create_table(pa_schema=schema)
+        self._write_arrow(table, pa.table({
+            'a': [None, None, 1, 1, 1, 1, 2],
+            'b': [None, 2, None, 1, 2, 2, 1],
+            'c': [0, 0, 0, 0, 0, 1, 0],
+        }, schema=schema))
+        composite = _java_composite_file()
+        builder = table.new_read_builder().new_predicate_builder()
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+
+        self.assertIsNone(DataEvolutionGlobalIndexScanner.create(
+            table, index_files=[composite], snapshot=snapshot))
+        with DataEvolutionGlobalIndexScanner.create(
+                table, index_files=[composite], snapshot=snapshot,
+                allow_composite=True) as scanner:
+            evaluation = scanner.scan_with_coverage(builder.equal('a', 1))
+            self.assertEqual([2, 3, 4, 5], 
evaluation.result.results().to_list())
+            self.assertEqual((Range(0, 6),), evaluation.indexed_ranges)
+
+        table.create_global_index('c')
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        scalar_files = [entry.index_file for entry in 
IndexFileHandler(table).scan(snapshot)]
+        predicate = PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.equal('b', 2), builder.equal('c', 
1),
+        ])
+        gate = threading.Barrier(2, timeout=3)
+        original = BTreeIndexReader.__init__
+
+        def concurrent_init(reader, *args, **kwargs):
+            gate.wait()
+            original(reader, *args, **kwargs)
+
+        with patch.object(BTreeIndexReader, '__init__', concurrent_init):
+            with DataEvolutionGlobalIndexScanner.create(
+                    table, index_files=[composite] + scalar_files, 
snapshot=snapshot,
+                    allow_composite=True) as scanner:
+                evaluation = scanner.scan_with_coverage(predicate)
+                self.assertEqual([5], evaluation.result.results().to_list())
+                self.assertEqual(frozenset([0, 1, 2]), 
evaluation.contributing_field_ids)
+                self.assertEqual((Range(0, 6),), evaluation.indexed_ranges)
+
+    @pytest.mark.python_plan
+    def test_composite_and_scalar_use_intersected_coverage(self):
+        schema = pa.schema([('a', pa.int32()), ('b', pa.int32()), ('c', 
pa.int32())])
+        table = self._create_table(pa_schema=schema)
+        self._write_arrow(table, pa.table({
+            'a': [None, None, 1, 1], 'b': [None, 2, None, 1],
+            'c': [0, 0, 0, 1],
+        }, schema=schema))
+        table.create_global_index('c')
+        self._write_arrow(table, pa.table({
+            'a': [1, 1, 2], 'b': [2, 2, 1], 'c': [0, 1, 0],
+        }, schema=schema))
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        scalar_entries = IndexFileHandler(table).scan(snapshot)
+        composite = _java_composite_file()
+        predicate = PredicateBuilder.and_predicates([
+            table.new_read_builder().new_predicate_builder().equal('a', 1),
+            table.new_read_builder().new_predicate_builder().equal('c', 1),
+        ])
+
+        with DataEvolutionGlobalIndexScanner.create(
+                table, index_files=[composite] + [e.index_file for e in 
scalar_entries],
+                snapshot=snapshot, allow_composite=True) as scanner:
+            evaluation = scanner.scan_with_coverage(predicate)
+            self.assertEqual([3], evaluation.result.results().to_list())
+            self.assertEqual((Range(0, 3),), evaluation.indexed_ranges)
+            self.assertEqual([Range(4, 6)], scanner.unindexed_ranges(
+                predicate, indexed_ranges=evaluation.indexed_ranges,
+                search_mode=table.copy({
+                    'scalar-index.search-mode': 'full'
+                }).options.scalar_index_search_mode()))
+
+        entries = [IndexManifestEntry(0, GenericRow([], []), 0, composite)] + 
scalar_entries
+        with patch.object(IndexFileHandler, 'scan', return_value=entries):
+            actual = self._rows(table.copy({'scalar-index.search-mode': 
'full'}), predicate)
+        expected = self._rows(table.copy({'global-index.enabled': 'false'}), 
predicate)
+        self.assertEqual(expected, actual)
+
+    @pytest.mark.python_plan
+    def test_composite_key_residual_keeps_fast_coverage(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.table({
+            'a': [None, None, 1, 1], 'b': [None, 2, None, 1],
+        }, schema=self.pa_schema))
+        table.create_global_index('b')
+        self._write_arrow(table, pa.table({
+            'a': [1, 1, 2], 'b': [2, 2, 1],
+        }, schema=self.pa_schema))
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        scalar_entries = IndexFileHandler(table).scan(snapshot)
+        composite = _java_composite_file()
+        entries = [IndexManifestEntry(0, GenericRow([], []), 0, composite)] + 
scalar_entries
+        builder = table.new_read_builder().new_predicate_builder()
+        predicate = PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.not_equal('b', 1),
+        ])
+        expected = self._rows(table.copy({'global-index.enabled': 'false'}), 
predicate)
+        self.assertEqual([{'a': 1, 'b': 2}] * 2, expected)
+
+        with DataEvolutionGlobalIndexScanner.create(
+                table, index_files=[composite] + [entry.index_file for entry 
in scalar_entries],
+                snapshot=snapshot, allow_composite=True) as scanner:
+            evaluation = scanner.scan_with_coverage(predicate)
+            self.assertEqual([2, 3, 4, 5], 
evaluation.result.results().to_list())
+            self.assertEqual((Range(0, 6),), evaluation.indexed_ranges)
+
+        with patch.object(IndexFileHandler, 'scan', return_value=entries):
+            for mode in ('full', 'fast'):
+                with self.subTest(mode=mode):
+                    actual = self._rows(
+                        table.copy({'scalar-index.search-mode': mode}), 
predicate)
+                    self.assertEqual(expected, actual)
+
+    def test_composite_plan_requires_left_prefix_and_bounded_in(self):
+        fields = [
+            DataField(0, 'a', AtomicType('INT')),
+            DataField(1, 'b', AtomicType('INT')),
+        ]
+        builder = PredicateBuilder(fields)
+        self.assertIsNone(plan_composite(fields, builder.equal('b', 1)))
+        self.assertIsNone(plan_composite(
+            fields, builder.is_in('a', list(range(257)))))
+        self.assertEqual(256, len(plan_composite(
+            fields, builder.is_in('a', list(range(256)))).intervals))
+        self.assertEqual(1, len(plan_composite(
+            fields, builder.is_in('a', [1, None])).intervals))
+        self.assertIsNone(plan_composite(fields, builder.less_than('a', 1.5)))
+        self.assertIsNone(plan_composite(fields, builder.equal('a', True)))
+        unsupported = [DataField(0, 'a', AtomicType('BINARY')), fields[1]]
+        self.assertIsNone(plan_composite(unsupported, builder.equal('a', 
b'x')))
+
+    def test_java_compacted_key_bytes_preserve_precision(self):
+        fields = [
+            DataField(0, 's', AtomicType('STRING')),
+            DataField(1, 'd', AtomicType('DECIMAL(38,18)')),
+            DataField(2, 't', AtomicType('TIMESTAMP(9)')),
+        ]
+        serializer = CompositeKeySerializer(fields)
+        expected = [
+            (None, None, None),
+            ('a', Decimal('1.230000000000000000'), Timestamp(0, 1000)),
+            ('x' * 130, Decimal('-99999999999999999999.999999999999999999'),
+             Timestamp(-1, 999999)),
+        ]
+        path = Path(__file__).parent / 
'resources/btree/java-v2-composite-keys.txt'
+        encoded_keys = [line for line in path.read_text().splitlines()
+                        if line and not line.startswith('#')]
+        self.assertEqual(len(expected), len(encoded_keys))
+        for row, encoded in zip(expected, encoded_keys):
+            with self.subTest(row=row):
+                self.assertEqual(row, 
serializer.deserialize(bytes.fromhex(encoded)))
+                self.assertEqual(encoded, serializer.serialize(row).hex())
+
+    def test_float_keys_keep_java_order_and_zero_predicates_fall_back(self):
+        fields = [
+            DataField(0, 'a', AtomicType('FLOAT')),
+            DataField(1, 'b', AtomicType('INT')),
+        ]
+        serializer = CompositeKeySerializer(fields)
+        self.assertLess(serializer.create_comparator()((-0.0, 1), (0.0, 1)), 0)
+        builder = PredicateBuilder(fields)
+        self.assertIsNone(plan_composite(fields, builder.equal('a', 0.0)))
+        self.assertIsNone(plan_composite(fields, builder.greater_or_equal('a', 
-0.0)))
+
+    def test_time_precision_does_not_break_prefix_reads(self):
+        fields = [
+            DataField(0, 'a', AtomicType('INT')),
+            DataField(1, 't', AtomicType('TIME(3)')),
+        ]
+        serializer = CompositeKeySerializer(fields)
+        self.assertEqual((1, 123), serializer.deserialize(
+            serializer.serialize((1, time(0, 0, 0, 123000)))))
+        builder = PredicateBuilder(fields)
+        self.assertIsNotNone(plan_composite(fields, builder.equal('a', 1)))
+        self.assertIsNotNone(plan_composite(fields, 
PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.equal('t', time(0, 0, 0, 
123000))])))
+        self.assertIsNone(plan_composite(fields, 
PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.less_than('t', time(0, 0, 0, 
123456))])))
+
+    @pytest.mark.python_plan
+    def test_time_submillisecond_bound_does_not_prune_matching_row(self):
+        schema = pa.schema([('a', pa.int32()), ('t', pa.time32('ms'))])
+        table = self._create_table(pa_schema=schema)
+        self._write_arrow(table, pa.table({
+            'a': [1], 't': [time(0, 0, 0, 123000)],
+        }, schema=schema))
+        index_path = 
table.path_factory().global_index_path_factory().index_path()
+        writer = BTreeIndexWriter(
+            table.file_io, index_path, CompositeKeySerializer(table.fields))
+        writer.write((1, 123), 0)
+        written = writer.finish()[0]
+        index_file = IndexFileMeta(
+            index_type='btree', file_name=written.file_name,
+            file_size=table.file_io.get_file_size(index_path + '/' + 
written.file_name),
+            row_count=1,
+            global_index_meta=GlobalIndexMeta(0, 0, 0, [1], written.meta),
+        )
+        entry = IndexManifestEntry(0, GenericRow([], []), 0, index_file)
+        builder = table.new_read_builder().new_predicate_builder()
+        predicate = PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.less_than('t', time(0, 0, 0, 
123456)),
+        ])
+        expected = self._rows(table.copy({'global-index.enabled': 'false'}), 
predicate)
+        self.assertEqual([{'a': 1, 't': time(0, 0, 0, 123000)}], expected)
+        with patch.object(IndexFileHandler, 'scan', return_value=[entry]):
+            actual = self._rows(table.copy({'scalar-index.search-mode': 
'full'}), predicate)
+        self.assertEqual(expected, actual)
+
+    @pytest.mark.python_plan
+    def test_corrupt_selected_composite_metadata_is_not_hidden(self):
+        table = self._create_table()
+        self._write_arrow(table, pa.table({
+            'a': [1], 'b': [2],
+        }, schema=self.pa_schema))
+        file = _java_composite_file()
+        file = replace(file, global_index_meta=replace(
+            file.global_index_meta, index_meta=b'broken'))
+        entry = IndexManifestEntry(0, GenericRow([], []), 0, file)
+        predicate = PredicateBuilder.and_predicates([
+            table.new_read_builder().new_predicate_builder().equal('a', 1),
+            table.new_read_builder().new_predicate_builder().equal('b', 2),
+        ])
+
+        with patch.object(IndexFileHandler, 'scan', return_value=[entry]):
+            with self.assertRaises(CompositeIndexReadError):
+                self._rows(table, predicate)
+
+    def test_unused_composite_preserves_scalar_read_parallelism(self):
+        fields = [DataField(i, name, AtomicType('INT'))
+                  for i, name in enumerate(('a', 'b', 'x', 'y'))]
+        builder = PredicateBuilder(fields)
+        predicate = PredicateBuilder.and_predicates([
+            builder.equal('b', 1), builder.equal('x', 1), builder.equal('y', 
1),
+        ])
+        file_io = LocalFileIO()
+        with tempfile.TemporaryDirectory() as index_path:
+            scalar_files = []
+            for field in fields[1:]:
+                writer = BTreeIndexWriter(
+                    file_io, index_path, create_serializer(field.type))
+                writer.write(1, 0)
+                written = writer.finish()[0]
+                scalar_files.append(IndexFileMeta(
+                    index_type='btree', file_name=written.file_name,
+                    file_size=file_io.get_file_size(index_path + '/' + 
written.file_name),
+                    row_count=7,
+                    global_index_meta=GlobalIndexMeta(
+                        0, 6, field.id, None, written.meta),
+                ))
+
+            gate = threading.Barrier(3, timeout=3)
+            original = BTreeIndexReader.__init__
+
+            def concurrent_init(reader, *args, **kwargs):
+                gate.wait()
+                original(reader, *args, **kwargs)
+
+            with patch.object(BTreeIndexReader, '__init__', concurrent_init):
+                with DataEvolutionGlobalIndexScanner(
+                        fields, file_io, index_path, scalar_files,
+                        thread_num=3, 
composite_files=[_java_composite_file()]) as scanner:
+                    evaluation = scanner.scan_with_coverage(predicate)
+            self.assertEqual([0], evaluation.result.results().to_list())
+            self.assertEqual(frozenset((1, 2, 3)), 
evaluation.contributing_field_ids)
+
+    def test_distinct_composite_files_initialize_concurrently(self):
+        fields = [DataField(i, name, AtomicType('INT'))
+                  for i, name in enumerate(('a', 'b'))]
+        builder = PredicateBuilder(fields)
+        predicate = PredicateBuilder.and_predicates([
+            builder.equal('a', 1), builder.equal('b', 2),
+        ])
+        source = _java_composite_file()
+        file_io = LocalFileIO()
+        with tempfile.TemporaryDirectory() as index_path:
+            files = []
+            for i in range(8):
+                path = str(Path(index_path) / ('shard-%d.btree' % i))
+                shutil.copyfile(source.external_path, path)
+                files.append(replace(
+                    source, file_name=Path(path).name, external_path=path,
+                    global_index_meta=replace(
+                        source.global_index_meta,
+                        row_range_start=i * 7, row_range_end=i * 7 + 6)))
+            files.append(replace(
+                files[0], global_index_meta=replace(
+                    files[0].global_index_meta,
+                    row_range_start=56, row_range_end=62)))
+
+            gate = threading.Barrier(8, timeout=3)
+            opened = []
+            lock = threading.Lock()
+            original = CompositeBTreeIndexReader.__init__
+
+            def concurrent_init(reader, *args, **kwargs):
+                with lock:
+                    opened.append(args[-1].external_path)
+                gate.wait()
+                original(reader, *args, **kwargs)
+
+            with patch.object(CompositeBTreeIndexReader, '__init__', 
concurrent_init):
+                with DataEvolutionGlobalIndexScanner(
+                        fields, file_io, index_path, [], thread_num=8,
+                        composite_files=files) as scanner:
+                    evaluation = scanner.scan_with_coverage(predicate)
+            self.assertEqual(8, len(opened))
+            self.assertEqual(8, len(set(opened)))
+            self.assertEqual(sorted([offset + row for offset in range(0, 63, 7)
+                                     for row in (4, 5)]),
+                             evaluation.result.results().to_list())
+
+    @staticmethod
+    def _rows(table, predicate):
+        builder = table.new_read_builder().with_filter(predicate)
+        return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
diff --git a/paimon-python/pypaimon/tests/global_index_test.py 
b/paimon-python/pypaimon/tests/global_index_test.py
index ec889da3bf..f1532a291e 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -201,7 +201,7 @@ class 
DataEvolutionGlobalIndexCoverageTest(unittest.TestCase):
             coverage.unindexed_ranges(table.fields, predicate),
         )
 
-    def test_extra_fields_count_as_index_coverage(self):
+    def test_composite_fields_do_not_count_as_scalar_coverage(self):
         from 
pypaimon.execution.python.globalindex.data_evolution_global_index_coverage 
import (
             DataEvolutionGlobalIndexCoverage,
         )
@@ -214,7 +214,7 @@ class 
DataEvolutionGlobalIndexCoverageTest(unittest.TestCase):
             [_coverage_index_file(0, 0, 9, extra_field_ids=[1])],
         )
 
-        self.assertEqual([], coverage.unindexed_ranges(1))
+        self.assertEqual([Range(0, 9)], coverage.unindexed_ranges(1))
 
     def test_detail_mode_uses_table_data_ranges(self):
         from 
pypaimon.execution.python.globalindex.data_evolution_global_index_coverage 
import (
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java 
b/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
index 04a101491b..ae0c087b1e 100644
--- a/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
+++ b/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
@@ -19,17 +19,24 @@
 package org.apache.paimon.globalindex.btree;
 
 import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.Decimal;
+import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.globalindex.KeySerializer;
+import org.apache.paimon.globalindex.CompositeKeySerializer;
 import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
 import org.apache.paimon.memory.MemorySlice;
 import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
 import org.apache.paimon.types.VarCharType;
 import org.apache.paimon.utils.BloomFilter;
 import org.apache.paimon.utils.LongArrayList;
 
 import java.io.ByteArrayOutputStream;
+import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -157,5 +164,119 @@ public final class BTreeFixtureGenerator {
                 }
             }
         }
+        writeComposite(directory, "java-v2-composite-int.btree", false);
+        writeComposite(directory, "java-v2-composite-ac.btree", false);
+        writeComposite(directory, "java-v2-composite-reversed.btree", true);
+        writeCompositeKeys(directory);
+    }
+
+    private static void writeComposite(Path directory, String name, boolean 
reversed)
+            throws Exception {
+        final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        GlobalIndexFileWriter files =
+                new GlobalIndexFileWriter() {
+                    @Override
+                    public String newFileName(String prefix) {
+                        return name;
+                    }
+
+                    @Override
+                    public PositionOutputStream newOutputStream(String 
fileName) {
+                        return new PositionOutputStream() {
+                            @Override
+                            public long getPos() {
+                                return bytes.size();
+                            }
+
+                            @Override
+                            public void write(int value) {
+                                bytes.write(value);
+                            }
+
+                            @Override
+                            public void write(byte[] value) {
+                                bytes.write(value, 0, value.length);
+                            }
+
+                            @Override
+                            public void write(byte[] value, int offset, int 
length) {
+                                bytes.write(value, offset, length);
+                            }
+
+                            @Override
+                            public void flush() {}
+
+                            @Override
+                            public void close() {}
+                        };
+                    }
+                };
+        BTreeIndexWriter writer =
+                new BTreeIndexWriter(
+                        files,
+                        new CompositeKeySerializer(RowType.of(DataTypes.INT(), 
DataTypes.INT())),
+                        32,
+                        null,
+                        null,
+                        2);
+        Integer[][] keys = {
+            {null, null}, {null, 2}, {1, null}, {1, 1}, {1, 2}, {1, 2}, {2, 1}
+        };
+        int[] order = reversed ? new int[] {0, 2, 3, 6, 1, 4, 5} : new int[] 
{0, 1, 2, 3, 4, 5, 6};
+        for (int rowId : order) {
+            Integer[] key = keys[rowId];
+            writer.write(GenericRow.of(
+                    reversed ? key[1] : key[0], reversed ? key[0] : key[1]), 
rowId);
+        }
+        ResultEntry entry = writer.finish().get(0);
+        Files.write(directory.resolve(name), bytes.toByteArray());
+        Files.write(directory.resolve(name + ".meta"), entry.meta());
+    }
+
+    private static void writeCompositeKeys(Path directory) throws Exception {
+        KeySerializer serializer =
+                new CompositeKeySerializer(
+                        RowType.of(
+                                DataTypes.STRING(), DataTypes.DECIMAL(38, 18),
+                                DataTypes.TIMESTAMP(9)));
+        GenericRow[] rows = {
+            GenericRow.of(null, null, null),
+            GenericRow.of(
+                    BinaryString.fromString("a"),
+                    Decimal.fromBigDecimal(new 
BigDecimal("1.230000000000000000"), 38, 18),
+                    Timestamp.fromEpochMillis(0, 1000)),
+            GenericRow.of(
+                    BinaryString.fromString(new String(new 
char[130]).replace('\0', 'x')),
+                    Decimal.fromBigDecimal(
+                            new 
BigDecimal("-99999999999999999999.999999999999999999"),
+                            38, 18),
+                    Timestamp.fromEpochMillis(-1, 999999)),
+        };
+        StringBuilder result =
+                new StringBuilder(
+                        "# Licensed to the Apache Software Foundation (ASF) 
under one\n"
+                                + "# or more contributor license agreements.  
See the NOTICE file\n"
+                                + "# distributed with this work for additional 
information\n"
+                                + "# regarding copyright ownership.  The ASF 
licenses this file\n"
+                                + "# to you under the Apache License, Version 
2.0 (the\n"
+                                + "# \"License\"); you may not use this file 
except in compliance\n"
+                                + "# with the License.  You may obtain a copy 
of the License at\n"
+                                + "#\n"
+                                + "#   
http://www.apache.org/licenses/LICENSE-2.0\n";
+                                + "#\n"
+                                + "# Unless required by applicable law or 
agreed to in writing, software\n"
+                                + "# distributed under the License is 
distributed on an \"AS IS\" BASIS,\n"
+                                + "# WITHOUT WARRANTIES OR CONDITIONS OF ANY 
KIND, either express or implied.\n"
+                                + "# See the License for the specific language 
governing permissions and\n"
+                                + "# limitations under the License.\n");
+        for (GenericRow row : rows) {
+            for (byte value : serializer.serialize(row)) {
+                result.append(String.format("%02x", value & 0xff));
+            }
+            result.append('\n');
+        }
+        Files.write(
+                directory.resolve("java-v2-composite-keys.txt"),
+                result.toString().getBytes(StandardCharsets.UTF_8));
     }
 }
diff --git a/paimon-python/pypaimon/tests/resources/btree/README.md 
b/paimon-python/pypaimon/tests/resources/btree/README.md
index bf708a7ffc..6da0d7b726 100644
--- a/paimon-python/pypaimon/tests/resources/btree/README.md
+++ b/paimon-python/pypaimon/tests/resources/btree/README.md
@@ -79,3 +79,8 @@ with tempfile.TemporaryDirectory() as classes:
 The fixtures were verified with Python 3.13 and pyroaring 1.1.0. These samples
 cover 64-bit portable Roaring payloads, including multiple high-word values and
 `Long.MAX_VALUE`. They do not establish compatibility with every pyroaring 
version.
+
+The composite fixtures were generated by `BTreeFixtureGenerator.java` from
+Java source at `e2cdaf54ffbfe84a86df7d0ca3fb1da2e2d0d8ba`. Python tests
+attach field ID tuples for `(a,b)`, `(a,c)`, and `(b,a)`; the key bytes contain
+ordered values, not field IDs. `java-v2-composite-keys.txt` checks mixed-type 
keys.
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree
new file mode 100644
index 0000000000..56d27480c0
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree differ
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree.meta 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree.meta
new file mode 100644
index 0000000000..ca7d97c30a
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-ac.btree.meta 
differ
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree
new file mode 100644
index 0000000000..56d27480c0
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree 
differ
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree.meta 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree.meta
new file mode 100644
index 0000000000..ca7d97c30a
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-int.btree.meta 
differ
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-keys.txt 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-keys.txt
new file mode 100644
index 0000000000..f68d9d73c5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-keys.txt
@@ -0,0 +1,18 @@
+# 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.
+0007
+00000161081111d67bb1bb00000000000000000000e807
+000082017878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787810b4c4b357a5793b85f675ddc000000001ffffffffffffffffbf843d
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree
new file mode 100644
index 0000000000..eb6e606cd2
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree 
differ
diff --git 
a/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree.meta
 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree.meta
new file mode 100644
index 0000000000..ca7d97c30a
Binary files /dev/null and 
b/paimon-python/pypaimon/tests/resources/btree/java-v2-composite-reversed.btree.meta
 differ

Reply via email to