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 0edf2c354b [python] Support BTree v2 posting list decoding (#10495)
0edf2c354b is described below
commit 0edf2c354bdb97d5bd9199d90e4b0737cec11d52
Author: QuakeWang <[email protected]>
AuthorDate: Mon Oct 12 21:25:58 2026 +0800
[python] Support BTree v2 posting list decoding (#10495)
---
.../globalindex/btree/btree_file_footer.py | 1 +
.../globalindex/btree/btree_index_reader.py | 27 +---
.../globalindex/btree/btree_posting_list.py | 77 ++++++++++
.../pypaimon/tests/btree_index_reader_test.py | 164 ++++++++++++++++++++-
.../pypaimon/tests/btree_posting_list_test.py | 111 ++++++++++++++
.../resources/btree/BTreeFixtureGenerator.java | 161 ++++++++++++++++++++
.../pypaimon/tests/resources/btree/README.md | 63 ++++++++
.../pypaimon/tests/resources/btree/expected.json | 7 +
.../tests/resources/btree/java-v1-bloom.btree | Bin 0 -> 2300 bytes
.../tests/resources/btree/java-v1-bloom.btree.meta | Bin 0 -> 19 bytes
.../resources/btree/java-v1-string-bloom.btree | Bin 0 -> 2296 bytes
.../btree/java-v1-string-bloom.btree.meta | Bin 0 -> 15 bytes
.../tests/resources/btree/java-v1-string.btree | Bin 0 -> 2290 bytes
.../resources/btree/java-v1-string.btree.meta | Bin 0 -> 15 bytes
.../pypaimon/tests/resources/btree/java-v1.btree | Bin 0 -> 2294 bytes
.../tests/resources/btree/java-v1.btree.meta | Bin 0 -> 19 bytes
.../tests/resources/btree/java-v2-bloom.btree | Bin 0 -> 324 bytes
.../tests/resources/btree/java-v2-bloom.btree.meta | Bin 0 -> 19 bytes
.../resources/btree/java-v2-string-bloom.btree | Bin 0 -> 320 bytes
.../btree/java-v2-string-bloom.btree.meta | Bin 0 -> 15 bytes
.../tests/resources/btree/java-v2-string.btree | Bin 0 -> 314 bytes
.../resources/btree/java-v2-string.btree.meta | Bin 0 -> 15 bytes
.../pypaimon/tests/resources/btree/java-v2.btree | Bin 0 -> 318 bytes
.../tests/resources/btree/java-v2.btree.meta | Bin 0 -> 19 bytes
.../pypaimon/tests/resources/btree/posting-1.bin | Bin 0 -> 2 bytes
.../pypaimon/tests/resources/btree/posting-2.bin | 1 +
.../pypaimon/tests/resources/btree/posting-3.bin | Bin 0 -> 28 bytes
.../pypaimon/tests/resources/btree/posting-4.bin | Bin 0 -> 28 bytes
.../pypaimon/tests/resources/btree/posting-5.bin | Bin 0 -> 28 bytes
.../tests/resources/btree/posting-high-buckets.bin | Bin 0 -> 47 bytes
paimon-python/pypaimon/utils/roaring_bitmap.py | 8 +
31 files changed, 597 insertions(+), 23 deletions(-)
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
b/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
index d50195f131..4b6940b0f6 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
@@ -47,6 +47,7 @@ class BTreeFileFooter:
MAGIC_NUMBER = 0x50425449
VERSION_1 = 1
+ VERSION_2 = 2
CURRENT_VERSION = VERSION_1
ENCODED_LENGTH = 52
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
index 8e9f21e6e3..007ccf4361 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
@@ -35,24 +35,10 @@ from pypaimon.globalindex.key_serializer import
KeySerializer
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
from pypaimon.globalindex.btree.btree_file_footer import BTreeFileFooter
from pypaimon.globalindex.btree.sst_file_reader import SstFileReader
-from pypaimon.globalindex.memory_slice_input import MemorySliceInput
+from pypaimon.globalindex.btree.btree_posting_list import add_row_ids
from pypaimon.globalindex.sorted_file_meta_selector import
SortedFileMetaSelector
-def _deserialize_row_ids(data: bytes) -> List[int]:
- data_input = MemorySliceInput(data)
- length = data_input.read_var_len_int()
-
- if length <= 0:
- raise ValueError(f"Invalid row id length: {length}")
-
- row_ids = []
- for _ in range(length):
- row_ids.append(data_input.read_var_len_long())
-
- return row_ids
-
-
class BTreeIndexReader:
"""Synchronous index reader for a single BTree index file.
@@ -97,7 +83,7 @@ class BTreeIndexReader:
# Read footer to get index and bloom filter handles
self.footer = self._read_footer()
- if self.footer.version != BTreeFileFooter.VERSION_1:
+ if self.footer.version not in (BTreeFileFooter.VERSION_1,
BTreeFileFooter.VERSION_2):
raise ValueError(
f"Unsupported BTree index file version:
{self.footer.version}")
@@ -195,8 +181,7 @@ class BTreeIndexReader:
if difference > 0 or (not to_inclusive and difference == 0):
return result
- for row_id in _deserialize_row_ids(entry.value):
- result.add(row_id)
+ add_row_ids(entry.value, self.footer.version, result)
return result
@@ -255,8 +240,7 @@ class BTreeIndexReader:
for entry in self._entries_from(prefix):
if upper_bound is not None and entry.key >= upper_bound:
break
- for row_id in _deserialize_row_ids(entry.value):
- result.add(row_id)
+ add_row_ids(entry.value, self.footer.version, result)
return GlobalIndexResult.create(result)
def visit_ends_with(self, literal: object) -> Optional[GlobalIndexResult]:
@@ -282,8 +266,7 @@ class BTreeIndexReader:
result = RoaringBitmap64()
row_ids = self.reader.lookup(self.key_serializer.serialize(key))
if row_ids is not None:
- for row_id in _deserialize_row_ids(row_ids):
- result.add(row_id)
+ add_row_ids(row_ids, self.footer.version, result)
return result
def close(self) -> None:
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_posting_list.py
b/paimon-python/pypaimon/globalindex/btree/btree_posting_list.py
new file mode 100644
index 0000000000..b9ba140ae8
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/btree/btree_posting_list.py
@@ -0,0 +1,77 @@
+# 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.
+
+"""Decode BTree postings into a query result bitmap."""
+
+from pypaimon.globalindex.btree.btree_file_footer import BTreeFileFooter
+from pypaimon.globalindex.memory_slice_input import MemorySliceInput
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+_SINGLE = 0
+_DELTA_LIST = 1
+_ROARING = 2
+_LONG_MAX_VALUE = (1 << 63) - 1
+
+
+def _read_row_id(input_: MemorySliceInput) -> int:
+ row_id = input_.read_var_len_long()
+ if row_id > _LONG_MAX_VALUE:
+ raise ValueError(f"BTree row id exceeds Long.MAX_VALUE: {row_id}")
+ return row_id
+
+
+def add_row_ids(data: bytes, version: int, target: RoaringBitmap64) -> None:
+ """Accumulate a posting; malformed input raises instead of returning a
result."""
+ input_ = MemorySliceInput(data)
+ if version == BTreeFileFooter.VERSION_1:
+ count = input_.read_var_len_int()
+ if count <= 0:
+ raise ValueError(f"Invalid row id length: {count}")
+ for _ in range(count):
+ target.add(input_.read_var_len_long())
+ return
+
+ if version != BTreeFileFooter.VERSION_2:
+ raise ValueError(f"Unsupported BTree index file version: {version}")
+
+ type_ = input_.read_unsigned_byte()
+ if type_ == _SINGLE:
+ target.add(_read_row_id(input_))
+ elif type_ == _DELTA_LIST:
+ count = input_.read_var_len_int()
+ if count <= 1 or count > (1 << 31) - 1:
+ raise ValueError(f"Invalid delta BTree posting list length:
{count}")
+ row_id = _read_row_id(input_)
+ target.add(row_id)
+ for _ in range(count - 1):
+ delta = _read_row_id(input_)
+ if delta == 0:
+ raise ValueError("Invalid non-positive BTree row id delta: 0")
+ row_id += delta
+ if row_id > _LONG_MAX_VALUE:
+ raise ValueError(f"BTree row id exceeds Long.MAX_VALUE:
{row_id}")
+ target.add(row_id)
+ elif type_ == _ROARING:
+ bitmap =
RoaringBitmap64.deserialize(input_.read_slice(input_.available()))
+ if bitmap.is_empty():
+ raise ValueError("Invalid empty Roaring BTree posting list")
+ if bitmap.max() > _LONG_MAX_VALUE:
+ raise ValueError("BTree row id exceeds Long.MAX_VALUE")
+ target.or_inplace(bitmap)
+ else:
+ raise ValueError(f"Unknown BTree posting list type: {type_}")
diff --git a/paimon-python/pypaimon/tests/btree_index_reader_test.py
b/paimon-python/pypaimon/tests/btree_index_reader_test.py
index c1e396467e..9f8b9e8c9f 100644
--- a/paimon-python/pypaimon/tests/btree_index_reader_test.py
+++ b/paimon-python/pypaimon/tests/btree_index_reader_test.py
@@ -18,17 +18,24 @@
"""File-level tests for BTree reader versions and initialization cleanup."""
import io
+import json
import os
import struct
import tempfile
import unittest
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+from unittest.mock import patch
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.globalindex.btree.btree_file_footer import BTreeFileFooter
from pypaimon.globalindex.btree.btree_index_reader import BTreeIndexReader
from pypaimon.globalindex.btree.btree_index_writer import BTreeIndexWriter
+from pypaimon.globalindex.btree.lazy_filtered_btree_reader import
LazyFilteredBTreeReader
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
+from pypaimon.globalindex.global_index_reader import FieldRef
from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.globalindex.offset_global_index_reader import
OffsetGlobalIndexReader
from pypaimon.globalindex.sorted_file_meta_selector import
SortedFileMetaSelector
from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
from pypaimon.schema.data_types import AtomicType
@@ -182,7 +189,7 @@ class BTreeIndexReaderTest(unittest.TestCase):
def test_unsupported_versions_rejected_before_sst_read(self):
for input_type in (_TrackingInput, _PreadInput):
- for version in (0, 2, 3, 0x80000000, 0xffffffff):
+ for version in (0, 3, 0x80000000, 0xffffffff):
with self.subTest(input_type=input_type, version=version):
data = self.data[:-8] + struct.pack('<I', version) +
self.data[-4:]
file_io = self._file_io(data, input_type)
@@ -251,5 +258,160 @@ class BTreeIndexReaderTest(unittest.TestCase):
self.serializer, file_io, '/unused', self.io_meta)
+class JavaBTreeIndexReaderTest(unittest.TestCase):
+
+ def setUp(self):
+ self.directory = Path(__file__).parent / 'resources' / 'btree'
+ self.serializer = create_serializer(AtomicType('INT'))
+ self.rows = {int(k): v for k, v in json.loads(
+ (self.directory / 'expected.json').read_text()).items()}
+
+ def _meta(self, version, bloom=False, string=False):
+ name = 'java-v%d%s%s.btree' % (version, '-string' if string else '',
'-bloom' if bloom else '')
+ data = (self.directory / name).read_bytes()
+ return data, GlobalIndexIOMeta(
+ name, len(data), (self.directory / (name + '.meta')).read_bytes())
+
+ def test_java_file_queries(self):
+ for version in (1, 2):
+ for bloom in (False, True):
+ for input_type in (_TrackingInput, _PreadInput):
+ with self.subTest(version=version, bloom=bloom,
input_type=input_type):
+ data, meta = self._meta(version, bloom)
+ file_io = _MemoryFileIO(data, input_type)
+ reader = BTreeIndexReader(self.serializer, file_io,
'/unused', meta)
+ try:
+ self.assertEqual(version, reader.footer.version)
+ self.assertEqual(bloom,
reader.footer.bloom_filter_handle is not None)
+ for key, expected in self.rows.items():
+ self.assertEqual(expected,
reader.visit_equal(key).results().to_list())
+ self.assertEqual([],
reader.visit_equal(6).results().to_list())
+ self.assertEqual([], reader.visit_between(6,
7).results().to_list())
+ self.assertEqual(
+ sorted(self.rows[2] + self.rows[3] +
self.rows[4]),
+ reader.visit_between(2, 4).results().to_list())
+ self.assertEqual(
+ self.rows[3], reader._range_query(2, 4, False,
False).to_list())
+ self.assertEqual(
+ sorted(self.rows[1] + self.rows[3]),
+ reader.visit_in([1, 3, 3, 6,
None]).results().to_list())
+ self.assertEqual(self.rows[1],
reader.visit_less_than(2).results().to_list())
+ self.assertEqual(self.rows[5],
reader.visit_greater_than(4).results().to_list())
+ self.assertEqual([999],
reader.visit_is_null().results().to_list())
+ self.assertEqual(
+ sorted(sum(self.rows.values(), [])),
+ reader.visit_is_not_null().results().to_list())
+ if version == 2:
+ iterator = reader.reader.create_iterator()
+ iterator.seek_to(self.serializer.serialize(1))
+ blocks = 0
+ keys = []
+ while True:
+ block = iterator.read_batch()
+ if block is None:
+ break
+ blocks += 1
+ while block.has_next():
+ entry = next(block)
+ key =
self.serializer.deserialize(entry.key)
+ keys.append(key)
+ self.assertEqual(
+ (self.directory /
('posting-%d.bin' % key)).read_bytes(),
+ entry.value)
+ self.assertEqual([1, 2, 3, 4, 5], keys)
+ self.assertGreater(blocks, 1)
+ finally:
+ reader.close()
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_java_string_prefix_queries(self):
+ serializer = create_serializer(AtomicType('STRING'))
+ keys = ['ke', 'keep', 'keeper', 'key', 'kf']
+ for version in (1, 2):
+ for bloom in (False, True):
+ for input_type in (_TrackingInput, _PreadInput):
+ with self.subTest(version=version, bloom=bloom,
input_type=input_type):
+ data, meta = self._meta(version, bloom, string=True)
+ file_io = _MemoryFileIO(data, input_type)
+ reader = BTreeIndexReader(serializer, file_io,
'/unused', meta)
+ try:
+ for prefix in ('', 'ke', 'keep', 'keeper', 'key',
'kf', 'absent', 'kg'):
+ with self.subTest(prefix=prefix):
+ expected = sorted(
+ row_id for i, key in enumerate(keys, 1)
+ if key.startswith(prefix) for row_id
in self.rows[i])
+ result = reader.visit_starts_with(prefix)
+ self.assertTrue(result.is_exact())
+ self.assertEqual(expected,
result.results().to_list())
+ iterator = reader.reader.create_iterator()
+ iterator.seek_to(serializer.serialize(''))
+ blocks = 0
+ seen = []
+ while True:
+ block = iterator.read_batch()
+ if block is None:
+ break
+ blocks += 1
+ while block.has_next():
+ entry = next(block)
+ key = serializer.deserialize(entry.key)
+ seen.append(key)
+ if version == 2:
+ self.assertEqual(
+ (self.directory /
('posting-%d.bin' % (keys.index(key) + 1))).read_bytes(),
+ entry.value)
+ self.assertEqual(keys, seen)
+ self.assertGreater(blocks, 1)
+ self.assertEqual([999],
reader.visit_is_null().results().to_list())
+ finally:
+ reader.close()
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_v2_initialization_failure_preserves_error_and_closes_stream(self):
+ data, meta = self._meta(2)
+ footer =
BTreeFileFooter.read_footer(data[-BTreeFileFooter.ENCODED_LENGTH:])
+ damaged = bytearray(data)
+ handle = footer.index_block_handle
+ damaged[handle.offset + handle.size + 1] ^= 1
+ for input_type in (_TrackingInput, _PreadInput):
+ for close_error in (None, OSError('Close failed')):
+ with self.subTest(input_type=input_type,
close_error=close_error):
+ file_io = _MemoryFileIO(bytes(damaged), input_type,
close_error=close_error)
+ with self.assertRaisesRegex(ValueError, 'CRC32 mismatch'):
+ BTreeIndexReader(self.serializer, file_io, '/unused',
meta)
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_malformed_posting_fails_query(self):
+ data, meta = self._meta(2)
+ reader = BTreeIndexReader(self.serializer, _MemoryFileIO(data,
_TrackingInput), '/unused', meta)
+ try:
+ with patch.object(reader.reader, 'lookup',
return_value=b'\x01\x02\x0a'):
+ with self.assertRaises(IndexError):
+ reader.visit_equal(1)
+ finally:
+ reader.close()
+
+ def test_mixed_versions_with_range_offset(self):
+ metas = [self._meta(version)[1] for version in (1, 2)]
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ reader = LazyFilteredBTreeReader(
+ self.serializer, LocalFileIO(), str(self.directory), metas,
executor,
+ fallback_scan_max_size=1 << 20)
+ offset_reader = OffsetGlobalIndexReader(reader, 100, (1 << 63) - 1)
+ field = FieldRef(0, 'key', 'INT')
+ try:
+ result = offset_reader.visit_in(field, [1, 2,
3]).result(timeout=10)
+ expected = sorted(v + 100 for v in self.rows[1] + self.rows[2]
+ self.rows[3])
+ self.assertEqual(expected, result.results().to_list())
+ self.assertEqual(
+ sorted(v + 100 for v in self.rows[2] + self.rows[3]),
+ offset_reader.visit_between(field, 2,
3).result(timeout=10).results().to_list())
+ self.assertEqual(
+ [1099],
offset_reader.visit_is_null(field).result(timeout=10).results().to_list())
+ self.assertEqual({1, 2}, {r.footer.version for r in
reader._reader_cache.values()})
+ finally:
+ offset_reader.close()
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/paimon-python/pypaimon/tests/btree_posting_list_test.py
b/paimon-python/pypaimon/tests/btree_posting_list_test.py
new file mode 100644
index 0000000000..83670d43ef
--- /dev/null
+++ b/paimon-python/pypaimon/tests/btree_posting_list_test.py
@@ -0,0 +1,111 @@
+# 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 posting format and Java interoperability tests."""
+
+import json
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+from pypaimon.globalindex.btree.btree_posting_list import add_row_ids
+from pypaimon.utils.roaring_bitmap import RoaringBitmap64
+
+
+def _varint(value):
+ data = bytearray()
+ while value > 127:
+ data.append((value & 127) | 128)
+ value >>= 7
+ data.append(value)
+ return bytes(data)
+
+
+class BTreePostingListTest(unittest.TestCase):
+
+ def test_java_postings_accumulate_without_expanding_roaring(self):
+ directory = Path(__file__).parent / 'resources' / 'btree'
+ expected = json.loads((directory / 'expected.json').read_text())
+ result = RoaringBitmap64()
+ result.add(7)
+ all_rows = [7]
+ for key, type_ in ((1, 0), (2, 1), (3, 2), (4, 2), (5, 2)):
+ data = (directory / ('posting-%d.bin' % key)).read_bytes()
+ self.assertEqual(type_, data[0])
+ if type_ == 2:
+ with patch.object(RoaringBitmap64, '__iter__',
side_effect=AssertionError('Expanded bitmap')), \
+ patch.object(RoaringBitmap64, 'to_list',
side_effect=AssertionError('Expanded bitmap')):
+ add_row_ids(data, 2, result)
+ else:
+ add_row_ids(data, 2, result)
+ all_rows.extend(expected[str(key)])
+ self.assertEqual(sorted(all_rows), result.to_list())
+
+ def test_java_roaring_crosses_high_word_buckets(self):
+ directory = Path(__file__).parent / 'resources' / 'btree'
+ data = (directory / 'posting-high-buckets.bin').read_bytes()
+ self.assertEqual(2, data[0])
+ result = RoaringBitmap64()
+ add_row_ids(data, 2, result)
+ self.assertEqual(list(range((1 << 32) - 64, (1 << 32) + 64)),
result.to_list())
+
+ def test_single_and_delta_numeric_boundaries(self):
+ values = [0, 127, 128, 16383, 16384, (1 << 32) - 1, 1 << 32, (1 << 63)
- 1]
+ for value in values:
+ with self.subTest(value=value):
+ result = RoaringBitmap64()
+ add_row_ids(b'\x00' + _varint(value), 2, result)
+ self.assertEqual([value], result.to_list())
+ data = b'\x01' + _varint(len(values)) + _varint(values[0])
+ data += b''.join(_varint(b - a) for a, b in zip(values, values[1:]))
+ result = RoaringBitmap64()
+ add_row_ids(data, 2, result)
+ self.assertEqual(values, result.to_list())
+
+ def test_v1_uses_absolute_ids_and_footer_version(self):
+ for values in ([10], [10, 20], [0, 128, 1 << 32, (1 << 63) - 1]):
+ with self.subTest(values=values):
+ result = RoaringBitmap64()
+ data = _varint(len(values)) + b''.join(_varint(v) for v in
values)
+ add_row_ids(data, 1, result)
+ self.assertEqual(values, result.to_list())
+
+ def test_rejects_invalid_postings(self):
+ oversized = _varint(1 << 63)
+ invalid = [
+ b'', b'\x63', b'\x00', b'\x00\x80',
+ b'\x01', b'\x01\x80', b'\x01\x00', b'\x01\x01\x0a',
+ b'\x01' + _varint(1 << 31), b'\x01\x02',
+ b'\x01\x02\x80', b'\x01\x02\x0a', b'\x01\x02\x0a\x80',
+ b'\x01\x02\x0a\x00', b'\x00' + oversized,
+ b'\x01\x02' + oversized, b'\x01\x02\x00' + oversized,
+ b'\x01\x02' + _varint((1 << 63) - 1) + b'\x01',
+ b'\x02', b'\x02invalid', b'\x02' + RoaringBitmap64().serialize(),
+ ]
+ bitmap = RoaringBitmap64()
+ bitmap.add(1 << 63)
+ invalid.append(b'\x02' + bitmap.serialize())
+ directory = Path(__file__).parent / 'resources' / 'btree'
+ invalid.append((directory / 'posting-3.bin').read_bytes()[:-1])
+ for data in invalid:
+ with self.subTest(data=data):
+ with self.assertRaises((ValueError, IndexError)):
+ add_row_ids(data, 2, RoaringBitmap64())
+
+ def test_rejects_unknown_version(self):
+ with self.assertRaisesRegex(ValueError, 'Unsupported BTree index file
version: 3'):
+ add_row_ids(b'\x00\x0a', 3, RoaringBitmap64())
diff --git
a/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
b/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
new file mode 100644
index 0000000000..04a101491b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/resources/btree/BTreeFixtureGenerator.java
@@ -0,0 +1,161 @@
+/*
+ * 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.data.BinaryString;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.globalindex.KeySerializer;
+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.VarCharType;
+import org.apache.paimon.utils.BloomFilter;
+import org.apache.paimon.utils.LongArrayList;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+
+/** Generates Python interoperability fixtures using the actual Java writer. */
+public final class BTreeFixtureGenerator {
+
+ public static void main(String[] args) throws Exception {
+ Path directory = Paths.get(args[0]);
+ Files.createDirectories(directory);
+ long[][] rows = new long[5][];
+ rows[0] = new long[] {10};
+ rows[1] = new long[] {20, 40, (1L << 32) + 1};
+ rows[2] = new long[128];
+ rows[3] = new long[128];
+ rows[4] = new long[128];
+ for (int i = 0; i < 128; i++) {
+ rows[2][i] = 1000 + i;
+ rows[3][i] = (1L << 33) + i;
+ rows[4][i] = Long.MAX_VALUE - 127 + i;
+ }
+ int[] expectedTypes = {0, 1, 2, 2, 2};
+ StringBuilder expected = new StringBuilder("{\n");
+ for (int i = 0; i < rows.length; i++) {
+ LongArrayList list = new LongArrayList(rows[i].length);
+ for (long row : rows[i]) {
+ list.add(row);
+ }
+ byte[] encoded = BTreePostingList.serialize(list);
+ if ((encoded[0] & 255) != expectedTypes[i]) {
+ throw new AssertionError("Unexpected posting encoding");
+ }
+ long[] decoded =
+ BTreePostingList.deserialize(MemorySlice.wrap(encoded),
Integer.MAX_VALUE);
+ if (!Arrays.equals(decoded, rows[i])) {
+ throw new AssertionError("Java posting mismatch");
+ }
+ Files.write(directory.resolve("posting-" + (i + 1) + ".bin"),
encoded);
+ expected.append('"').append(i + 1).append("\":
").append(Arrays.toString(rows[i]));
+ expected.append(i + 1 == rows.length ? "\n}\n" : ",\n");
+ }
+ Files.write(
+ directory.resolve("expected.json"),
+ expected.toString().getBytes(StandardCharsets.UTF_8));
+ LongArrayList crossing = new LongArrayList(128);
+ for (int i = 0; i < 128; i++) {
+ crossing.add((1L << 32) - 64 + i);
+ }
+ byte[] crossingPosting = BTreePostingList.serialize(crossing);
+ if ((crossingPosting[0] & 255) != BTreePostingList.ROARING) {
+ throw new AssertionError("Expected Roaring across high-word
buckets");
+ }
+ Files.write(directory.resolve("posting-high-buckets.bin"),
crossingPosting);
+ String[] stringKeys = {"ke", "keep", "keeper", "key", "kf"};
+ for (int version = 1; version <= 2; version++) {
+ for (boolean bloom : new boolean[] {false, true}) {
+ for (boolean string : new boolean[] {false, true}) {
+ final String name =
+ "java-v"
+ + version
+ + (string ? "-string" : "")
+ + (bloom ? "-bloom" : "")
+ + ".btree";
+ final ByteArrayOutputStream bytes = new
ByteArrayOutputStream();
+ GlobalIndexFileWriter fileWriter =
+ 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(
+ fileWriter,
+ string
+ ?
KeySerializer.create(VarCharType.STRING_TYPE)
+ : KeySerializer.create(new
IntType()),
+ 32,
+ bloom ?
BloomFilter.fixedBuilder(rows.length, 0.01) : null,
+ null,
+ version);
+ for (int i = 0; i < rows.length; i++) {
+ for (long row : rows[i]) {
+ writer.write(
+ string ?
BinaryString.fromString(stringKeys[i]) : i + 1, row);
+ }
+ }
+ writer.write(null, 999);
+ ResultEntry entry = writer.finish().get(0);
+ Files.write(directory.resolve(name), bytes.toByteArray());
+ Files.write(directory.resolve(name + ".meta"),
entry.meta());
+ }
+ }
+ }
+ }
+}
diff --git a/paimon-python/pypaimon/tests/resources/btree/README.md
b/paimon-python/pypaimon/tests/resources/btree/README.md
new file mode 100644
index 0000000000..a521c23512
--- /dev/null
+++ b/paimon-python/pypaimon/tests/resources/btree/README.md
@@ -0,0 +1,63 @@
+# Java BTree interoperability fixtures
+
+These files are generated by `BTreeFixtureGenerator.java` using the
repository's
+`BTreeIndexWriter` and `BTreePostingList`. They are not Python round-trip
fixtures.
+
+Each `java-v{1,2}[-bloom].btree` uses INT keys, no block compression, a 32-byte
+target block size, and an independent null bitmap containing row ID 999.
+The matching `.meta` file contains the writer's serialized index metadata.
+`expected.json` records the non-null row IDs for each key:
+
+| Key | Rows | v2 encoding |
+| --- | --- | --- |
+| 1 | 10 | SINGLE (0) |
+| 2 | 20, 40, 4294967297 | DELTA_LIST (1) |
+| 3 | 1000 through 1127 | ROARING (2) |
+| 4 | 8589934592 through 8589934719 | ROARING (2) |
+| 5 | 9223372036854775680 through 9223372036854775807 | ROARING (2) |
+
+`posting-{1..5}.bin` contains the Java v2 posting for each key. The generator
+checks the actual encoding tag and the Java round-trip. Python file tests
compare
+the postings extracted from the SST blocks against these bytes. Python posting
+tests check the tags and compare decoded row IDs against `expected.json`.
+`posting-high-buckets.bin` is an additional Java Roaring posting covering
+4294967232 through 4294967359, across two high-word buckets in one payload.
+
+`java-v{1,2}-string[-bloom].btree` and their `.meta` files use STRING keys
+`ke`, `keep`, `keeper`, `key`, and `kf`, with the same postings and null
bitmap.
+These fixtures verify exact prefix queries across blocks, including all three
+v2 encodings and the exclusive prefix upper bound.
+
+To regenerate from the repository root with JDK 8, first build and run the Java
+posting tests to obtain the reactor's exact classpath:
+
+```shell
+mvn -pl paimon-common -am -DwildcardSuites=none \
+ -DfailIfNoTests=false -Dtest=BTreePostingListTest test
+```
+
+Then compile and run the generator without adding it to production sources:
+
+```python
+from pathlib import Path
+import subprocess
+import tempfile
+import xml.etree.ElementTree as ET
+
+root = Path.cwd()
+directory = root / 'paimon-python/pypaimon/tests/resources/btree'
+report = root /
'paimon-common/target/surefire-reports/TEST-org.apache.paimon.globalindex.btree.BTreePostingListTest.xml'
+properties = ET.parse(report).getroot().find('properties')
+classpath = next(p.attrib['value'] for p in properties
+ if p.attrib['name'] == 'java.class.path')
+with tempfile.TemporaryDirectory() as classes:
+ subprocess.run(['javac', '-source', '8', '-target', '8', '-cp', classpath,
+ '-d', classes, str(directory /
'BTreeFixtureGenerator.java')], check=True)
+ subprocess.run(['java', '-cp', classes + ':' + classpath,
+
'org.apache.paimon.globalindex.btree.BTreeFixtureGenerator',
+ str(directory)], check=True)
+```
+
+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.
diff --git a/paimon-python/pypaimon/tests/resources/btree/expected.json
b/paimon-python/pypaimon/tests/resources/btree/expected.json
new file mode 100644
index 0000000000..e6f5fb295e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/resources/btree/expected.json
@@ -0,0 +1,7 @@
+{
+"1": [10],
+"2": [20, 40, 4294967297],
+"3": [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011,
1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, 1024,
1025, 1026, 1027, 1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037,
1038, 1039, 1040, 1041, 1042, 1043, 1044, 1045, 1046, 1047, 1048, 1049, 1050,
1051, 1052, 1053, 1054, 1055, 1056, 1057, 1058, 1059, 1060, 1061, 1062, 1063,
1064, 1065, 1066, 1067, 1068, 1069, 1070, 1071, 1072, 1073, 1074, 1075, 1076,
1077, 1078, 1079, 1080, [...]
+"4": [8589934592, 8589934593, 8589934594, 8589934595, 8589934596, 8589934597,
8589934598, 8589934599, 8589934600, 8589934601, 8589934602, 8589934603,
8589934604, 8589934605, 8589934606, 8589934607, 8589934608, 8589934609,
8589934610, 8589934611, 8589934612, 8589934613, 8589934614, 8589934615,
8589934616, 8589934617, 8589934618, 8589934619, 8589934620, 8589934621,
8589934622, 8589934623, 8589934624, 8589934625, 8589934626, 8589934627,
8589934628, 8589934629, 8589934630, 8589934631, 858993 [...]
+"5": [9223372036854775680, 9223372036854775681, 9223372036854775682,
9223372036854775683, 9223372036854775684, 9223372036854775685,
9223372036854775686, 9223372036854775687, 9223372036854775688,
9223372036854775689, 9223372036854775690, 9223372036854775691,
9223372036854775692, 9223372036854775693, 9223372036854775694,
9223372036854775695, 9223372036854775696, 9223372036854775697,
9223372036854775698, 9223372036854775699, 9223372036854775700,
9223372036854775701, 9223372036854775702, 922 [...]
+}
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree
new file mode 100644
index 0000000000..96f18ceffe
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree.meta
new file mode 100644
index 0000000000..a3ac99b2c5
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-bloom.btree.meta differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree
new file mode 100644
index 0000000000..1c15806467
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree.meta
new file mode 100644
index 0000000000..71a5dc84be
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string-bloom.btree.meta
differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree
new file mode 100644
index 0000000000..ba1d3a448a
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree.meta
new file mode 100644
index 0000000000..71a5dc84be
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1-string.btree.meta differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v1.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v1.btree
new file mode 100644
index 0000000000..f1d5a8d2d8
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1.btree differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v1.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v1.btree.meta
new file mode 100644
index 0000000000..a3ac99b2c5
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v1.btree.meta differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree
new file mode 100644
index 0000000000..832d6f96ad
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree.meta
new file mode 100644
index 0000000000..a3ac99b2c5
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-bloom.btree.meta differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree
new file mode 100644
index 0000000000..ebd7a39e52
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree.meta
new file mode 100644
index 0000000000..71a5dc84be
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string-bloom.btree.meta
differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree
new file mode 100644
index 0000000000..3e69c491a1
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree.meta
new file mode 100644
index 0000000000..71a5dc84be
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2-string.btree.meta differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v2.btree
b/paimon-python/pypaimon/tests/resources/btree/java-v2.btree
new file mode 100644
index 0000000000..6f4d7957b7
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2.btree differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/java-v2.btree.meta
b/paimon-python/pypaimon/tests/resources/btree/java-v2.btree.meta
new file mode 100644
index 0000000000..a3ac99b2c5
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/java-v2.btree.meta differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/posting-1.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-1.bin
new file mode 100644
index 0000000000..1f2a4f5ef3
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/posting-1.bin differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/posting-2.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-2.bin
new file mode 100644
index 0000000000..d8d877e9b8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/resources/btree/posting-2.bin
@@ -0,0 +1 @@
+����
\ No newline at end of file
diff --git a/paimon-python/pypaimon/tests/resources/btree/posting-3.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-3.bin
new file mode 100644
index 0000000000..984f6ddd4f
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/posting-3.bin differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/posting-4.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-4.bin
new file mode 100644
index 0000000000..60639129e5
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/posting-4.bin differ
diff --git a/paimon-python/pypaimon/tests/resources/btree/posting-5.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-5.bin
new file mode 100644
index 0000000000..a8c691262f
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/posting-5.bin differ
diff --git
a/paimon-python/pypaimon/tests/resources/btree/posting-high-buckets.bin
b/paimon-python/pypaimon/tests/resources/btree/posting-high-buckets.bin
new file mode 100644
index 0000000000..3e4749bea9
Binary files /dev/null and
b/paimon-python/pypaimon/tests/resources/btree/posting-high-buckets.bin differ
diff --git a/paimon-python/pypaimon/utils/roaring_bitmap.py
b/paimon-python/pypaimon/utils/roaring_bitmap.py
index 41b0a6e4d8..22a2994a67 100644
--- a/paimon-python/pypaimon/utils/roaring_bitmap.py
+++ b/paimon-python/pypaimon/utils/roaring_bitmap.py
@@ -54,6 +54,10 @@ class RoaringBitmap64:
"""Return the number of elements in the bitmap."""
return len(self._data)
+ def max(self) -> int:
+ """Return the largest value in a non-empty bitmap."""
+ return self._data.max()
+
def __iter__(self) -> Iterator[int]:
"""Iterate over all values in the bitmap in sorted order."""
return iter(self._data)
@@ -127,6 +131,10 @@ class RoaringBitmap64:
"""Remove all values contained in ``other`` from this bitmap, in
place."""
self._data -= other._data
+ def or_inplace(self, other: 'RoaringBitmap64') -> None:
+ """Add all values from another bitmap in place."""
+ self._data |= other._data
+
def serialize(self) -> bytes:
"""Serialize the bitmap to bytes."""
return self._data.serialize()