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 79830f5ec2 [python] Reject unsupported BTree index file versions
(#10395)
79830f5ec2 is described below
commit 79830f5ec24f40ce190cbc5716102f17e39b4bec
Author: QuakeWang <[email protected]>
AuthorDate: Fri Oct 9 22:02:52 2026 +0800
[python] Reject unsupported BTree index file versions (#10395)
---
.../globalindex/btree/btree_file_footer.py | 3 +-
.../globalindex/btree/btree_index_reader.py | 33 ++--
.../pypaimon/tests/btree_index_reader_test.py | 186 +++++++++++++++++++++
3 files changed, 209 insertions(+), 13 deletions(-)
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
b/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
index 2dbf315146..d50195f131 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_file_footer.py
@@ -46,7 +46,8 @@ class BTreeFileFooter:
"""
MAGIC_NUMBER = 0x50425449
- CURRENT_VERSION = 1
+ VERSION_1 = 1
+ CURRENT_VERSION = VERSION_1
ENCODED_LENGTH = 52
def __init__(
diff --git a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
index f553960886..f45e8e9e9d 100644
--- a/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
+++ b/paimon-python/pypaimon/globalindex/btree/btree_index_reader.py
@@ -24,6 +24,7 @@ multiple files is handled by LazyFilteredBTreeReader.
import struct
import threading
import zlib
+from contextlib import suppress
from typing import List, Optional
from pypaimon.common.file_io import FileIO, supports_pread, pread
@@ -85,18 +86,26 @@ class BTreeIndexReader:
if io_meta.external_path
else index_path + "/" + io_meta.file_name)
self.input_stream = file_io.new_input_stream(file_path)
- self._supports_pread = supports_pread(self.input_stream)
- self._io_lock = threading.Lock()
-
- # Lazy-loaded null bitmap
- self._null_bitmap: Optional[RoaringBitmap64] = None
- self._null_bitmap_lock = threading.Lock()
-
- # Read footer to get index and bloom filter handles
- self.footer = self._read_footer()
-
- # Initialize SST file reader
- self.reader = self._create_sst_reader()
+ try:
+ self._supports_pread = supports_pread(self.input_stream)
+ self._io_lock = threading.Lock()
+
+ # Lazy-loaded null bitmap
+ self._null_bitmap: Optional[RoaringBitmap64] = None
+ self._null_bitmap_lock = threading.Lock()
+
+ # Read footer to get index and bloom filter handles
+ self.footer = self._read_footer()
+ if self.footer.version != BTreeFileFooter.VERSION_1:
+ raise ValueError(
+ f"Unsupported BTree index file version:
{self.footer.version}")
+
+ # Initialize SST file reader
+ self.reader = self._create_sst_reader()
+ except BaseException:
+ with suppress(Exception):
+ self.input_stream.close()
+ raise
def _read_from(self, offset: int, length: int) -> bytes:
if self._supports_pread:
diff --git a/paimon-python/pypaimon/tests/btree_index_reader_test.py
b/paimon-python/pypaimon/tests/btree_index_reader_test.py
new file mode 100644
index 0000000000..45184640d1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/btree_index_reader_test.py
@@ -0,0 +1,186 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""File-level tests for BTree reader versions and initialization cleanup."""
+
+import io
+import os
+import struct
+import tempfile
+import unittest
+
+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.global_index_meta import GlobalIndexIOMeta
+from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.schema.data_types import AtomicType
+
+
+class _TrackingInput(io.BytesIO):
+
+ def __init__(self, data, read_error=None, close_error=None):
+ super().__init__(data)
+ self.read_ranges = []
+ self.read_error = read_error
+ self.close_error = close_error
+
+ def read(self, size=-1):
+ self.read_ranges.append((self.tell(), size))
+ if self.read_error is not None:
+ raise self.read_error
+ return super().read(size)
+
+ def close(self):
+ super().close()
+ if self.close_error is not None:
+ raise self.close_error
+
+
+class _PreadInput(_TrackingInput):
+
+ def read_at(self, size, offset):
+ self.read_ranges.append((offset, size))
+ if self.read_error is not None:
+ raise self.read_error
+ return self.getvalue()[offset:offset + size]
+
+
+class _MemoryFileIO:
+
+ def __init__(self, data, input_type, read_error=None, close_error=None):
+ self.input_stream = input_type(data, read_error, close_error)
+
+ def new_input_stream(self, path):
+ return self.input_stream
+
+
+class BTreeIndexReaderTest(unittest.TestCase):
+
+ def setUp(self):
+ self.serializer = create_serializer(AtomicType('INT'))
+ with tempfile.TemporaryDirectory() as directory:
+ file_io = LocalFileIO()
+ writer = BTreeIndexWriter(
+ file_io, directory, self.serializer, block_size=32)
+ for key, row_id in (
+ (10, 7), (20, 10), (20, 30), (20, 60),
+ (30, 100), (30, 299), (None, 400),
+ ):
+ writer.write(key, row_id)
+ entry = writer.finish()[0]
+ with open(os.path.join(directory, entry.file_name), 'rb') as
stream:
+ self.data = stream.read()
+ self.io_meta = GlobalIndexIOMeta(
+ entry.file_name, len(self.data), entry.meta)
+
+ def test_version_1_queries_and_close(self):
+ for input_type in (_TrackingInput, _PreadInput):
+ with self.subTest(input_type=input_type):
+ file_io = self._file_io(self.data, input_type)
+ reader = self._reader(file_io)
+ try:
+ self.assertEqual(1, reader.footer.version)
+ self.assertEqual([7],
reader.visit_equal(10).results().to_list())
+ self.assertEqual([10, 30, 60],
reader.visit_equal(20).results().to_list())
+ self.assertEqual([],
reader.visit_equal(25).results().to_list())
+ self.assertEqual(
+ [7, 10, 30, 60], reader.visit_between(10,
20).results().to_list())
+ self.assertEqual(
+ [10, 30, 60, 100, 299], reader.visit_between(20,
30).results().to_list())
+ self.assertEqual([7],
reader.visit_less_than(20).results().to_list())
+ self.assertEqual([100, 299],
reader.visit_greater_than(20).results().to_list())
+ self.assertEqual([400],
reader.visit_is_null().results().to_list())
+ self.assertFalse(file_io.input_stream.closed)
+ finally:
+ reader.close()
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_unsupported_versions_rejected_before_sst_read(self):
+ for input_type in (_TrackingInput, _PreadInput):
+ for version in (0, 2, 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)
+ with self.assertRaisesRegex(
+ ValueError, 'Unsupported BTree index file version: %s'
% version,
+ ):
+ self._reader(file_io)
+ self.assertTrue(file_io.input_stream.closed)
+ self.assertEqual(
+ [(len(data) - BTreeFileFooter.ENCODED_LENGTH,
+ BTreeFileFooter.ENCODED_LENGTH)],
+ file_io.input_stream.read_ranges,
+ )
+
+ def test_invalid_footer_closes_stream(self):
+ bad_magic = self.data[:-4] + struct.pack('<I', 0)
+ for input_type in (_TrackingInput, _PreadInput):
+ for data, error in ((bad_magic, ValueError), (self.data[:-4],
struct.error)):
+ with self.subTest(input_type=input_type, error=error):
+ file_io = self._file_io(data, input_type)
+ with self.assertRaises(error):
+ self._reader(file_io)
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_invalid_index_block_closes_stream(self):
+ footer =
BTreeFileFooter.read_footer(self.data[-BTreeFileFooter.ENCODED_LENGTH:])
+ handle = footer.index_block_handle
+ data = bytearray(self.data)
+ data[handle.offset + handle.size + 1] ^= 1
+ for input_type in (_TrackingInput, _PreadInput):
+ with self.subTest(input_type=input_type):
+ file_io = self._file_io(bytes(data), input_type)
+ with self.assertRaisesRegex(ValueError, 'CRC32 mismatch'):
+ self._reader(file_io)
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_read_failure_closes_stream(self):
+ for input_type in (_TrackingInput, _PreadInput):
+ for error in (OSError('Footer read failed'), KeyboardInterrupt()):
+ with self.subTest(input_type=input_type, error=type(error)):
+ file_io = self._file_io(self.data, input_type,
read_error=error)
+ with self.assertRaises(type(error)) as raised:
+ self._reader(file_io)
+ self.assertIs(error, raised.exception)
+ self.assertTrue(file_io.input_stream.closed)
+
+ def test_close_failure_preserves_initialization_error(self):
+ for input_type in (_TrackingInput, _PreadInput):
+ with self.subTest(input_type=input_type):
+ error = OSError('Footer read failed')
+ file_io = self._file_io(
+ self.data, input_type, read_error=error,
+ close_error=OSError('Stream close failed'))
+ with self.assertRaises(OSError) as raised:
+ self._reader(file_io)
+ self.assertIs(error, raised.exception)
+ self.assertTrue(file_io.input_stream.closed)
+
+ def _file_io(self, data, input_type, **kwargs):
+ file_io = _MemoryFileIO(data, input_type, **kwargs)
+ self.addCleanup(io.BytesIO.close, file_io.input_stream)
+ return file_io
+
+ def _reader(self, file_io):
+ return BTreeIndexReader(
+ self.serializer, file_io, '/unused', self.io_meta)
+
+
+if __name__ == '__main__':
+ unittest.main()