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 acf19b491e [python] Replace blob index entry limit with a memory 
budget (#10151)
acf19b491e is described below

commit acf19b491e88bcdbf7a0c6a5cf711f74eaa01920
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Oct 2 18:27:54 2026 +0800

    [python] Replace blob index entry limit with a memory budget (#10151)
---
 docs/docs/pypaimon/blob.md                         |   6 +
 paimon-python/pypaimon/catalog/catalog_context.py  |  39 ++++++-
 .../pypaimon/catalog/catalog_environment.py        |  10 +-
 paimon-python/pypaimon/catalog/catalog_factory.py  |   2 +-
 .../pypaimon/catalog/filesystem_catalog.py         |   9 +-
 .../pypaimon/catalog/filesystem_catalog_loader.py  |   2 +-
 .../pypaimon/catalog/rest/rest_catalog.py          |   3 +-
 paimon-python/pypaimon/common/blob_index_cache.py  |  93 +++++++++++++++
 paimon-python/pypaimon/common/options/config.py    |   7 ++
 .../pypaimon/read/reader/format_blob_reader.py     |  16 ++-
 paimon-python/pypaimon/read/split_read.py          |   4 +-
 paimon-python/pypaimon/tests/blob_table_test.py    |  29 +++--
 paimon-python/pypaimon/tests/blob_test.py          | 129 ++++++++++++++++++++-
 .../pypaimon/tests/catalog_environment_test.py     |  35 ++++++
 14 files changed, 345 insertions(+), 39 deletions(-)

diff --git a/docs/docs/pypaimon/blob.md b/docs/docs/pypaimon/blob.md
index 56126ef5b2..d09b3c252e 100644
--- a/docs/docs/pypaimon/blob.md
+++ b/docs/docs/pypaimon/blob.md
@@ -176,6 +176,12 @@ The factory auto-dispatches based on the bytes content 
(`BLOBDESC`,
 `VIDEOFRM`, or blob-view magic header). This mirrors Java's
 `Blob.fromBytes(...)`.
 
+## Python BLOB index cache
+
+Set `cache.blob-index.max-size` in Catalog options to limit parsed indexes per
+Catalog context (default: `64 mb`; `0 b` disables). The cache is process-local
+and only used by the Python BLOB reader.
+
 ## See Also
 
 - [Blob Storage](../multimodal-table/blob) — concept, storage modes,
diff --git a/paimon-python/pypaimon/catalog/catalog_context.py 
b/paimon-python/pypaimon/catalog/catalog_context.py
index ea9138a7a2..2facc7aa2f 100644
--- a/paimon-python/pypaimon/catalog/catalog_context.py
+++ b/paimon-python/pypaimon/catalog/catalog_context.py
@@ -15,20 +15,53 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from pypaimon.common.blob_index_cache import BlobIndexCache
 from pypaimon.common.options import Options
+from pypaimon.common.options.config import CatalogOptions
 
 
 class CatalogContext:
-    def __init__(self, options: Options, hadoop_conf, prefer_loader, 
fallback_io_loader):
+    def __init__(self, options: Options, hadoop_conf, prefer_loader,
+                 fallback_io_loader, blob_index_cache=None):
+        if isinstance(options, dict):
+            options = Options(options)
         self.options = options
+        self.blob_index_cache = blob_index_cache
+        if self.blob_index_cache is None:
+            self.blob_index_cache = BlobIndexCache(
+                options.get(CatalogOptions.CACHE_BLOB_INDEX_MAX_SIZE))
         self.hadoop_conf = hadoop_conf
         self.prefer_io_loader = prefer_loader
         self.fallback_io_loader = fallback_io_loader
 
     @staticmethod
-    def create(options: Options, hadoop_conf, prefer_loader, 
fallback_io_loader):
-        return CatalogContext(options, hadoop_conf, prefer_loader, 
fallback_io_loader)
+    def create(options: Options, hadoop_conf, prefer_loader, 
fallback_io_loader,
+               blob_index_cache=None):
+        return CatalogContext(
+            options,
+            hadoop_conf,
+            prefer_loader,
+            fallback_io_loader,
+            blob_index_cache,
+        )
 
     @staticmethod
     def create_from_options(options: Options):
         return CatalogContext(options, None, None, None)
+
+    def with_options(self, options: Options):
+        if isinstance(options, dict):
+            options = Options(options)
+        max_size = 
options.get(CatalogOptions.CACHE_BLOB_INDEX_MAX_SIZE).get_bytes()
+        cache = (
+            self.blob_index_cache
+            if self.blob_index_cache.max_size_bytes == max_size
+            else None
+        )
+        return CatalogContext(
+            options,
+            self.hadoop_conf,
+            self.prefer_io_loader,
+            self.fallback_io_loader,
+            cache,
+        )
diff --git a/paimon-python/pypaimon/catalog/catalog_environment.py 
b/paimon-python/pypaimon/catalog/catalog_environment.py
index badb147340..0f15594cad 100644
--- a/paimon-python/pypaimon/catalog/catalog_environment.py
+++ b/paimon-python/pypaimon/catalog/catalog_environment.py
@@ -101,6 +101,9 @@ class CatalogEnvironment:
         context = getattr(self.catalog_loader, "context", None)
         return context() if callable(context) else None
 
+    def blob_index_cache(self):
+        return getattr(self.catalog_context(), "blob_index_cache", None)
+
     def dependency_read_context(self) -> Optional[CatalogContext]:
         context = self.catalog_context()
         if self.identifier is None or context is None:
@@ -123,12 +126,7 @@ class CatalogEnvironment:
         dependency_options.to_map()[self._READ_VIA_OPTION] = 
RESTUtil.encode_string(
             JSON.to_json(self.identifier, separators=(",", ":"))
         )
-        return CatalogContext.create(
-            dependency_options,
-            context.hadoop_conf,
-            context.prefer_io_loader,
-            context.fallback_io_loader,
-        )
+        return context.with_options(dependency_options)
 
     def copy(self, identifier: Identifier) -> 'CatalogEnvironment':
         """
diff --git a/paimon-python/pypaimon/catalog/catalog_factory.py 
b/paimon-python/pypaimon/catalog/catalog_factory.py
index b28de94e70..0712e1e032 100644
--- a/paimon-python/pypaimon/catalog/catalog_factory.py
+++ b/paimon-python/pypaimon/catalog/catalog_factory.py
@@ -57,7 +57,7 @@ class CatalogFactory:
                 "Unknown catalog identifier: {}. Available types: {}".format(
                     identifier, list(CatalogFactory.CATALOG_REGISTRY.keys())))
         if identifier == "filesystem":
-            return catalog_class(context.options)
+            return catalog_class(context.options, context)
         if identifier == "rest":
             return catalog_class(context, config_required=config_required)
         return catalog_class(context)
diff --git a/paimon-python/pypaimon/catalog/filesystem_catalog.py 
b/paimon-python/pypaimon/catalog/filesystem_catalog.py
index ccd5b83296..52c42f163d 100644
--- a/paimon-python/pypaimon/catalog/filesystem_catalog.py
+++ b/paimon-python/pypaimon/catalog/filesystem_catalog.py
@@ -50,12 +50,17 @@ from pypaimon.table.table import Table
 
 
 class FileSystemCatalog(Catalog):
-    def __init__(self, catalog_options: Options):
+    def __init__(self, catalog_options: Options,
+                 catalog_context: Optional[CatalogContext] = None):
         if not catalog_options.contains(CatalogOptions.WAREHOUSE):
             raise ValueError(f"Paimon '{CatalogOptions.WAREHOUSE.key()}' path 
must be set")
         self.warehouse = catalog_options.get(CatalogOptions.WAREHOUSE)
         self.catalog_options = catalog_options
-        self.catalog_context = 
CatalogContext.create_from_options(catalog_options)
+        self.catalog_context = (
+            catalog_context
+            if catalog_context is not None
+            else CatalogContext.create_from_options(catalog_options)
+        )
         self._cache_manager = 
CachingFileIO.create_cache_manager(self.catalog_options)
         self.file_io = CachingFileIO.wrap_with_caching_if_needed(
             FileIO.get(self.warehouse, self.catalog_options), 
self.catalog_options,
diff --git a/paimon-python/pypaimon/catalog/filesystem_catalog_loader.py 
b/paimon-python/pypaimon/catalog/filesystem_catalog_loader.py
index f7a29d0970..46b840d16e 100644
--- a/paimon-python/pypaimon/catalog/filesystem_catalog_loader.py
+++ b/paimon-python/pypaimon/catalog/filesystem_catalog_loader.py
@@ -64,4 +64,4 @@ class FileSystemCatalogLoader(CatalogLoader):
         Returns:
             A new FileSystemCatalog instance
         """
-        return FileSystemCatalog(self._context.options)
+        return FileSystemCatalog(self._context.options, self._context)
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py 
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index 1b7f58e4f6..6f6b72d043 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -71,8 +71,7 @@ class RESTCatalog(Catalog):
     def __init__(self, context: CatalogContext, config_required: 
Optional[bool] = True):
         self.warehouse = context.options.get(CatalogOptions.WAREHOUSE)
         self.rest_api = RESTApi(context.options, config_required)
-        self.context = CatalogContext.create(self.rest_api.options, 
context.hadoop_conf,
-                                             context.prefer_io_loader, 
context.fallback_io_loader)
+        self.context = context.with_options(self.rest_api.options)
         self.data_token_enabled = 
self.rest_api.options.get(CatalogOptions.DATA_TOKEN_ENABLED)
         self._cache_manager = 
CachingFileIO.create_cache_manager(self.context.options)
 
diff --git a/paimon-python/pypaimon/common/blob_index_cache.py 
b/paimon-python/pypaimon/common/blob_index_cache.py
new file mode 100644
index 0000000000..611ed14312
--- /dev/null
+++ b/paimon-python/pypaimon/common/blob_index_cache.py
@@ -0,0 +1,93 @@
+# 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.
+
+import sys
+from collections import namedtuple
+from threading import Lock
+
+from cachetools import LRUCache
+
+from pypaimon.common.memory_size import MemorySize
+
+
+_CachedBlobIndex = namedtuple(
+    "_CachedBlobIndex", ["blob_lengths", "blob_offsets", "size_bytes"]
+)
+_CACHED_ENTRY_OVERHEAD = sys.getsizeof(_CachedBlobIndex((), (), 0))
+_ESTIMATED_INT_SIZE = sys.getsizeof(1 << 60)
+# Allow for cachetools' two dict slots, OrderedDict node, and allocator slack.
+_LRU_ENTRY_OVERHEAD = 256
+
+
+class BlobIndexCache:
+    """Catalog-owned parsed indexes bounded by estimated retained bytes."""
+
+    def __init__(self, max_size):
+        if not isinstance(max_size, MemorySize):
+            raise ValueError("cache.blob-index.max-size must be a memory size")
+        self.max_size_bytes = max_size.get_bytes()
+        self._cache = LRUCache(
+            maxsize=self.max_size_bytes,
+            getsizeof=lambda entry: entry.size_bytes,
+        )
+        self._lock = Lock()
+
+    def get(self, file_path):
+        if self.max_size_bytes == 0:
+            return None
+        with self._lock:
+            entry = self._cache.get(file_path)
+        if entry is None:
+            return None
+        return entry.blob_lengths, entry.blob_offsets
+
+    def put(self, file_path, blob_lengths, blob_offsets):
+        if self.max_size_bytes == 0:
+            return
+        size_bytes = (
+            sys.getsizeof(file_path)
+            + sys.getsizeof(blob_lengths)
+            + sys.getsizeof(blob_offsets)
+            + _CACHED_ENTRY_OVERHEAD
+            + _LRU_ENTRY_OVERHEAD
+            + (len(blob_lengths) + len(blob_offsets)) * _ESTIMATED_INT_SIZE
+        )
+        if size_bytes > self.max_size_bytes:
+            return
+        entry = _CachedBlobIndex(blob_lengths, blob_offsets, size_bytes)
+        with self._lock:
+            self._cache[file_path] = entry
+
+    def __len__(self):
+        with self._lock:
+            return len(self._cache)
+
+    def __contains__(self, file_path):
+        with self._lock:
+            return file_path in self._cache
+
+    def clear(self):
+        with self._lock:
+            self._cache.clear()
+
+    @property
+    def size_bytes(self):
+        with self._lock:
+            return self._cache.currsize
+
+    def __reduce__(self):
+        return type(self), (MemorySize.of_bytes(self.max_size_bytes),)
diff --git a/paimon-python/pypaimon/common/options/config.py 
b/paimon-python/pypaimon/common/options/config.py
index d6d627bd3e..e670309579 100644
--- a/paimon-python/pypaimon/common/options/config.py
+++ b/paimon-python/pypaimon/common/options/config.py
@@ -87,6 +87,13 @@ class PVFSOptions:
 
 
 class CatalogOptions:
+    CACHE_BLOB_INDEX_MAX_SIZE = (
+        ConfigOptions.key("cache.blob-index.max-size")
+        .memory_type()
+        .default_value(MemorySize.of_mebi_bytes(64))
+        .with_description("Maximum estimated Python BLOB index cache size per 
catalog.")
+    )
+
     URI = 
ConfigOptions.key("uri").string_type().no_default_value().with_description("Catalog
 URI")
     METASTORE = 
ConfigOptions.key("metastore").string_type().default_value("filesystem").with_description(
         "Metastore type")
diff --git a/paimon-python/pypaimon/read/reader/format_blob_reader.py 
b/paimon-python/pypaimon/read/reader/format_blob_reader.py
index e39e08b7b2..c959228cff 100644
--- a/paimon-python/pypaimon/read/reader/format_blob_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_blob_reader.py
@@ -16,17 +16,17 @@
 # under the License.
 
 import struct
-from threading import Lock
 from typing import List, Optional, Any, Iterator, BinaryIO
 
 import pyarrow as pa
 import pyarrow.dataset as ds
-from cachetools import LRUCache
 from pyarrow import RecordBatch
 
+from pypaimon.common.blob_index_cache import BlobIndexCache
 from pypaimon.common.delta_varint_compressor import DeltaVarintCompressor
 from pypaimon.common.file_io import FileIO
 from pypaimon.common.map_blob_key_serializer import 
create_map_blob_key_serializer
+from pypaimon.common.memory_size import MemorySize
 from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
 from pypaimon.read.reader.video_format_reader import (
     VideoFileMeta,
@@ -45,8 +45,7 @@ from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.row.row_kind import RowKind
 
 
-_BLOB_INDEX_CACHE = LRUCache(maxsize=16)
-_BLOB_INDEX_CACHE_LOCK = Lock()
+_BLOB_INDEX_CACHE = BlobIndexCache(MemorySize.of_mebi_bytes(64))
 
 
 def _decode_blob_index(index_bytes):
@@ -70,7 +69,8 @@ class FormatBlobReader(RecordBatchReader):
     def __init__(self, file_io: FileIO, file_path: str, read_fields: List[str],
                  full_fields: List[DataField], push_down_predicate: Any, 
blob_as_descriptor: bool,
                  batch_size: int = 1024, row_indices: Optional[Any] = None,
-                 blob_parallelism: int = 1, file_size: Optional[int] = None):
+                 blob_parallelism: int = 1, file_size: Optional[int] = None, 
index_cache=None):
+        self._index_cache = _BLOB_INDEX_CACHE if index_cache is None else 
index_cache
         self._file_io = file_io
         self._file_path = file_path
         self._push_down_predicate = push_down_predicate
@@ -374,8 +374,7 @@ class FormatBlobReader(RecordBatchReader):
             )
             return
 
-        with _BLOB_INDEX_CACHE_LOCK:
-            cached_index = _BLOB_INDEX_CACHE.get(self.file_path)
+        cached_index = self._index_cache.get(self.file_path)
         if cached_index is not None:
             blob_lengths, blob_offsets = cached_index
             self.blob_lengths = list(blob_lengths)
@@ -407,8 +406,7 @@ class FormatBlobReader(RecordBatchReader):
             raise IOError("Invalid blob file: cannot read index")
 
         blob_lengths, blob_offsets = _decode_blob_index(index_bytes)
-        with _BLOB_INDEX_CACHE_LOCK:
-            _BLOB_INDEX_CACHE[self.file_path] = blob_lengths, blob_offsets
+        self._index_cache.put(self.file_path, blob_lengths, blob_offsets)
         self.blob_lengths = list(blob_lengths)
         self.blob_offsets = list(blob_offsets)
 
diff --git a/paimon-python/pypaimon/read/split_read.py 
b/paimon-python/pypaimon/read/split_read.py
index 50a2c0ea7a..88883f61a2 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -405,7 +405,8 @@ class SplitRead(ABC):
                                              batch_size=batch_size,
                                              row_indices=row_indices,
                                              blob_parallelism=blob_parallelism,
-                                             file_size=file.file_size)
+                                             file_size=file.file_size,
+                                             
index_cache=self.table.catalog_environment.blob_index_cache())
         elif file_format == CoreOptions.FILE_FORMAT_LANCE:
             if has_nested:
                 raise NotImplementedError(
@@ -1761,6 +1762,7 @@ class DataEvolutionSplitRead(SplitRead):
             row_indices=row_indices,
             blob_parallelism=blob_parallelism,
             file_size=file.file_size,
+            index_cache=self.table.catalog_environment.blob_index_cache(),
         )
 
     def _split_field_bunches(self, need_merge_files: List[DataFileMeta]) -> 
List[FieldBunch]:
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py 
b/paimon-python/pypaimon/tests/blob_table_test.py
index ae4c5aa032..75b6aa405b 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -2692,9 +2692,13 @@ class DedicatedFormatWriterTest(unittest.TestCase):
         self.assertEqual(result.column('pic1').to_pylist()[0], pic1_data)
         self.assertEqual(result.column('pic2').to_pylist()[0], pic2_data)
 
+    @pytest.mark.python_read
     def test_blob_view_fields_resolve_upstream_blob(self):
+        from unittest import mock
+
         from pypaimon import Schema
         from pypaimon.common.options.core_options import CoreOptions
+        from pypaimon.read.reader import format_blob_reader
         from pypaimon.table.row.blob import BlobViewStruct
 
         source_schema = pa.schema([
@@ -2761,15 +2765,24 @@ class DedicatedFormatWriterTest(unittest.TestCase):
             "Blob view fields should be stored inline without writing new blob 
files",
         )
 
-        result = target_table.new_read_builder().new_read().to_arrow(
-            target_table.new_read_builder().new_scan().plan().splits()
-        ).sort_by('id')
-        self.assertEqual(result.column('picture').to_pylist(), payloads)
+        with mock.patch.object(
+                format_blob_reader,
+                '_decode_blob_index',
+                wraps=format_blob_reader._decode_blob_index,
+        ) as decode_index:
+            result = target_table.new_read_builder().new_read().to_arrow(
+                target_table.new_read_builder().new_scan().plan().splits()
+            ).sort_by('id')
+            self.assertEqual(result.column('picture').to_pylist(), payloads)
 
-        descriptor_table = 
target_table.copy({CoreOptions.BLOB_AS_DESCRIPTOR.key(): 'true'})
-        descriptor_result = 
descriptor_table.new_read_builder().new_read().to_arrow(
-            descriptor_table.new_read_builder().new_scan().plan().splits()
-        ).sort_by('id')
+            descriptor_table = target_table.copy({
+                CoreOptions.BLOB_AS_DESCRIPTOR.key(): 'true'
+            })
+            descriptor_result = 
descriptor_table.new_read_builder().new_read().to_arrow(
+                descriptor_table.new_read_builder().new_scan().plan().splits()
+            ).sort_by('id')
+
+        self.assertEqual(1, decode_index.call_count)
         # With blob-as-descriptor=true, view fields return BlobDescriptor bytes
         from pypaimon.table.row.blob import BlobDescriptor
         for value in descriptor_result.column('picture').to_pylist():
diff --git a/paimon-python/pypaimon/tests/blob_test.py 
b/paimon-python/pypaimon/tests/blob_test.py
index 9beb2829cb..f08eab83df 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -3181,6 +3181,120 @@ class BlobEndToEndTest(unittest.TestCase):
                 [BlobData(value)], [field], RowKind.INSERT))
             writer.close()
 
+    def test_blob_index_cache_size_controls_repeated_file_reads(self):
+        from pypaimon.catalog.catalog_context import CatalogContext
+
+        field = DataField(0, "blob_field", AtomicType("BLOB"))
+        file_io = LocalFileIO(self.temp_dir, Options({}))
+        paths = [os.path.join(self.temp_dir, "cache-{}.blob".format(i)) for i 
in range(17)]
+        for i, path in enumerate(paths):
+            self._write_single_blob(path, field, bytes([i]))
+        open_stream = file_io.new_input_stream
+        for max_size, expected_reads, expected_entries in [
+            ("1 kb", 34, None),
+            ("1 mb", 0, 17),
+            ("0 b", 34, 0),
+        ]:
+            with self.subTest(max_size=max_size):
+                cache = CatalogContext.create_from_options(
+                    Options({"cache.blob-index.max-size": 
max_size})).blob_index_cache
+                streams = []
+
+                def counted_open(path):
+                    stream = MagicMock(wraps=open_stream(path))
+                    streams.append(stream)
+                    return stream
+
+                with patch.object(file_io, "new_input_stream", 
side_effect=counted_open):
+                    results = []
+                    reads = []
+                    for _ in range(2):
+                        streams.clear()
+                        descriptors = []
+                        for path in paths:
+                            reader = FormatBlobReader(
+                                file_io, path, [field.name], [field], None, 
True,
+                                index_cache=cache)
+                            try:
+                                
descriptors.extend(reader.read_arrow_batch().column(0).to_pylist())
+                            finally:
+                                reader.close()
+                        results.append(descriptors)
+                        reads.append(sum(stream.read.call_count for stream in 
streams))
+                    self.assertEqual(reads, [34, expected_reads])
+                    self.assertEqual(results[0], results[1])
+                    self.assertLessEqual(cache.size_bytes, 
cache.max_size_bytes)
+                    if expected_entries is not None:
+                        self.assertEqual(len(cache), expected_entries)
+
+    def test_blob_index_cache_skips_oversized_entry(self):
+        from pypaimon.common.blob_index_cache import BlobIndexCache
+        from pypaimon.common.memory_size import MemorySize
+
+        cache = BlobIndexCache(MemorySize.of_kibi_bytes(1))
+        cache.put("small.blob", (1,), (0,))
+        self.assertEqual(((1,), (0,)), cache.get("small.blob"))
+
+        values = tuple(range(100))
+        cache.put("oversized.blob", values, values)
+        self.assertIsNone(cache.get("oversized.blob"))
+        self.assertEqual(((1,), (0,)), cache.get("small.blob"))
+
+    def test_blob_index_cache_accounts_for_many_small_entries(self):
+        from pypaimon.common.blob_index_cache import BlobIndexCache
+        from pypaimon.common.memory_size import MemorySize
+
+        cache = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+        for i in range(10000):
+            cache.put("small-{:05d}.blob".format(i), (1,), (0,))
+
+        self.assertLess(len(cache), 2048)
+        self.assertLessEqual(cache.size_bytes, cache.max_size_bytes)
+        self.assertNotIn("small-00000.blob", cache)
+        self.assertIn("small-09999.blob", cache)
+
+    def test_blob_index_cache_catalog_scope_and_serialization(self):
+        import pickle
+        from pypaimon import CatalogFactory, Schema
+        from pypaimon.catalog.catalog_context import CatalogContext
+
+        self.assertEqual(
+            64 * 1024 * 1024,
+            CatalogContext.create_from_options(
+                Options({})).blob_index_cache.max_size_bytes,
+        )
+        for value in ("-1 mb", "invalid"):
+            with self.subTest(value=value), self.assertRaises(ValueError):
+                CatalogContext.create_from_options(
+                    Options({"cache.blob-index.max-size": value}))
+
+        options = {"warehouse": self.temp_dir, "cache.blob-index.max-size": 
"64 kb"}
+        catalog = CatalogFactory.create(options)
+        catalog.create_database("db", True)
+        for name in ("a", "b"):
+            catalog.create_table("db." + name, Schema.from_pyarrow_schema(
+                pa.schema([("value", pa.binary())])), False)
+        first = catalog.get_table("db.a")
+        second = catalog.get_table("db.b")
+        cache = first.catalog_environment.blob_index_cache()
+        self.assertIs(cache, second.catalog_environment.blob_index_cache())
+        self.assertIs(cache, first.copy({"blob-as-descriptor": "true"})
+                      .catalog_environment.blob_index_cache())
+        cache.put("file.blob", (1,), (0,))
+
+        other = CatalogFactory.create(dict(options, 
**{"cache.blob-index.max-size": "32 kb"}))
+        other_cache = 
other.get_table("db.a").catalog_environment.blob_index_cache()
+        self.assertIsNot(cache, other_cache)
+        self.assertEqual(other_cache.max_size_bytes, 32 * 1024)
+        self.assertEqual(len(other_cache), 0)
+
+        restored = pickle.loads(pickle.dumps(first.catalog_environment))
+        restored_cache = restored.blob_index_cache()
+        self.assertEqual(restored_cache.max_size_bytes, 64 * 1024)
+        self.assertEqual(len(restored_cache), 0)
+        restored_cache.put("worker.blob", (1,), (0,))
+        self.assertIsNone(cache.get("worker.blob"))
+
     def test_blob_end_to_end(self):
         # Set up file I/O
         file_io = LocalFileIO(self.temp_dir, Options({}))
@@ -3292,14 +3406,13 @@ class BlobEndToEndTest(unittest.TestCase):
             reader.close()
 
     def test_blob_readers_reuse_index_by_path(self):
-        from pypaimon.read.reader.format_blob_reader import _BLOB_INDEX_CACHE, 
_BLOB_INDEX_CACHE_LOCK
+        from pypaimon.read.reader.format_blob_reader import _BLOB_INDEX_CACHE
 
         field = DataField(0, "blob_field", AtomicType("BLOB"))
         path = os.path.join(self.temp_dir, "cached-index.blob")
         file_io = CountingBlobFileIO(self.temp_dir)
         self._write_single_blob(path, field, b"cached-value")
-        with _BLOB_INDEX_CACHE_LOCK:
-            _BLOB_INDEX_CACHE.clear()
+        _BLOB_INDEX_CACHE.clear()
         results = []
 
         try:
@@ -3331,8 +3444,7 @@ class BlobEndToEndTest(unittest.TestCase):
                 self.assertEqual((path, 4, len(b"cached-value")),
                                  (descriptor.uri, descriptor.offset, 
descriptor.length))
         finally:
-            with _BLOB_INDEX_CACHE_LOCK:
-                _BLOB_INDEX_CACHE.clear()
+            _BLOB_INDEX_CACHE.clear()
 
     def test_blob_cached_index_keeps_payload_reads(self):
         field = DataField(0, "blob_field", AtomicType("BLOB"))
@@ -3556,13 +3668,18 @@ class BlobEndToEndTest(unittest.TestCase):
         evolution_read = DataEvolutionSplitRead(
             table, None, [field], split, False)
 
-        with patch("pypaimon.read.split_read.FormatBlobReader") as reader_cls:
+        from pypaimon.common.blob_index_cache import BlobIndexCache
+        from pypaimon.common.memory_size import MemorySize
+        cache = BlobIndexCache(MemorySize.of_kibi_bytes(32))
+        with patch.object(table.catalog_environment, "blob_index_cache", 
return_value=cache), \
+                patch("pypaimon.read.split_read.FormatBlobReader") as 
reader_cls:
             def assert_file_size():
                 args, kwargs = reader_cls.call_args
                 arguments = inspect.signature(FormatBlobReader).bind_partial(
                     *args, **kwargs
                 ).arguments
                 self.assertEqual(123, arguments.get("file_size"))
+                self.assertIs(cache, arguments.get("index_cache"))
 
             raw_read.file_reader_supplier(file, False, [field.name], False)
             assert_file_size()
diff --git a/paimon-python/pypaimon/tests/catalog_environment_test.py 
b/paimon-python/pypaimon/tests/catalog_environment_test.py
index 3908b6cb13..9ac2e56bf9 100644
--- a/paimon-python/pypaimon/tests/catalog_environment_test.py
+++ b/paimon-python/pypaimon/tests/catalog_environment_test.py
@@ -48,6 +48,10 @@ class CatalogEnvironmentTest(unittest.TestCase):
         dependency_context = environment.dependency_read_context()
 
         self.assertIsNot(dependency_context, context)
+        self.assertIs(
+            dependency_context.blob_index_cache,
+            context.blob_index_cache,
+        )
         self.assertFalse(context.options.contains_key(self._READ_VIA_OPTION))
         self.assertEqual(
             dependency_context.options.get(CatalogOptions.METASTORE), "rest")
@@ -98,6 +102,37 @@ class CatalogEnvironmentTest(unittest.TestCase):
 
         self.assertIsNot(environment.dependency_read_context(), context)
 
+    def test_filesystem_catalog_loader_preserves_context_cache(self):
+        context = CatalogContext.create_from_options(Options({
+            CatalogOptions.WAREHOUSE.key(): "/tmp/warehouse",
+        }))
+
+        catalog = FileSystemCatalogLoader(context).load()
+
+        self.assertIs(catalog.catalog_context, context)
+        self.assertIs(
+            catalog.catalog_context.blob_index_cache,
+            context.blob_index_cache,
+        )
+
+    def test_rest_catalog_preserves_context_cache(self):
+        from pypaimon.catalog.rest.rest_catalog import RESTCatalog
+
+        context = CatalogContext.create_from_options(Options({
+            CatalogOptions.WAREHOUSE.key(): "/tmp/warehouse",
+            CatalogOptions.METASTORE.key(): "rest",
+        }))
+        with mock.patch(
+                "pypaimon.catalog.rest.rest_catalog.RESTApi") as rest_api:
+            rest_api.return_value.options = context.options
+
+            catalog = RESTCatalog(context)
+
+        self.assertIs(
+            catalog.context.blob_index_cache,
+            context.blob_index_cache,
+        )
+
     def test_dependency_read_context_preserves_custom_rest_metastore(self):
         context = CatalogContext.create_from_options(Options({
             CatalogOptions.METASTORE.key(): "custom-rest",

Reply via email to