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 39a7c4cf0a [python] Deduplicate concurrent BLOB index loads (#10475)
39a7c4cf0a is described below

commit 39a7c4cf0a81cafb9794e59efba1b6f977691d4f
Author: QuakeWang <[email protected]>
AuthorDate: Tue Oct 13 13:31:11 2026 +0800

    [python] Deduplicate concurrent BLOB index loads (#10475)
---
 docs/docs/pypaimon/blob.md                         |   3 +
 .../pypaimon/benchmark/blob_index_cold_load.py     | 273 ++++++++++++++++
 paimon-python/pypaimon/common/blob_index_cache.py  |  83 ++++-
 .../pypaimon/read/reader/format_blob_reader.py     |  16 +-
 .../pypaimon/tests/blob_index_cache_test.py        | 353 +++++++++++++++++++++
 5 files changed, 701 insertions(+), 27 deletions(-)

diff --git a/docs/docs/pypaimon/blob.md b/docs/docs/pypaimon/blob.md
index d09b3c252e..085dc2a0dd 100644
--- a/docs/docs/pypaimon/blob.md
+++ b/docs/docs/pypaimon/blob.md
@@ -181,6 +181,9 @@ The factory auto-dispatches based on the bytes content 
(`BLOBDESC`,
 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.
+With caching enabled, concurrent readers of the same file share one in-flight
+index load. Failed loads can be retried; indexes exceeding the budget are 
shared
+only with current waiters and are not retained.
 
 ## See Also
 
diff --git a/paimon-python/pypaimon/benchmark/blob_index_cold_load.py 
b/paimon-python/pypaimon/benchmark/blob_index_cold_load.py
new file mode 100644
index 0000000000..be06d78028
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/blob_index_cold_load.py
@@ -0,0 +1,273 @@
+# 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.
+
+"""Observe BLOB index cold loads through public, disjoint shard reads.
+
+Requires Python 3.7+ for thread CPU timing. Cold means an empty Catalog
+index cache, not an empty OS page cache. No delay or barrier is injected
+inside a reader. Timing-only runs do not patch any production method.
+"""
+
+import argparse
+import json
+import platform
+import random
+import sys
+import tempfile
+import threading
+import time
+from collections import Counter
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import ExitStack
+from pathlib import Path
+from unittest.mock import patch
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.reader import format_blob_reader
+
+
+class _IndexProbe:
+    def __init__(self, file_io, cache):
+        self.file_io = file_io
+        self.cache = cache
+        self.local = threading.local()
+        self.lock = threading.Lock()
+        self.files = {}
+
+    def add(self, path, **values):
+        with self.lock:
+            counters = self.files.setdefault(path, Counter())
+            counters.update(values)
+
+    def install(self, stack):
+        original_index = format_blob_reader.FormatBlobReader._read_index
+        original_decode = format_blob_reader._decode_blob_index
+        io_type = type(self.file_io)
+        original_open = io_type.new_input_stream
+        probe = self
+
+        def read_index(reader):
+            assert reader._index_cache is probe.cache
+            probe.local.path = reader.file_path
+            probe.add(reader.file_path, reader_entries=1)
+            try:
+                return original_index(reader)
+            finally:
+                probe.local.path = None
+
+        def decode(data):
+            path = probe.local.path
+            started = time.thread_time()
+            result = original_decode(data)
+            probe.add(path, decodes=1, decoded_bytes=len(data),
+                      decode_cpu_ms=(time.thread_time() - started) * 1000)
+            return result
+
+        class Stream:
+            def __init__(self, wrapped, path):
+                self.wrapped = wrapped
+                self.path = path
+
+            def __getattr__(self, name):
+                return getattr(self.wrapped, name)
+
+            def read(self, size=-1):
+                data = self.wrapped.read(size)
+                if getattr(probe.local, 'path', None) == self.path:
+                    probe.add(self.path, index_read_calls=1,
+                              index_read_bytes=len(data))
+                return data
+
+            def __enter__(self):
+                self.wrapped.__enter__()
+                return self
+
+            def __exit__(self, *args):
+                return self.wrapped.__exit__(*args)
+
+        def open_stream(file_io, path):
+            stream = original_open(file_io, path)
+            if str(path).endswith('.blob'):
+                return Stream(stream, str(path))
+            return stream
+
+        stack.enter_context(patch.object(
+            format_blob_reader.FormatBlobReader, '_read_index', read_index))
+        stack.enter_context(patch.object(
+            format_blob_reader, '_decode_blob_index', decode))
+        stack.enter_context(patch.object(io_type, 'new_input_stream', 
open_stream))
+
+
+def _create_table(catalog, rows):
+    schema = pa.schema([('id', pa.int64()), ('payload', pa.large_binary())])
+    catalog.create_database('default', False)
+    catalog.create_table('default.blobs', Schema.from_pyarrow_schema(
+        schema, options={
+            'row-tracking.enabled': 'true',
+            'data-evolution.enabled': 'true',
+        }), False)
+    table = catalog.get_table('default.blobs')
+    expected = pa.table({
+        'id': list(range(rows)),
+        'payload': [('payload-%08d' % i).encode('ascii') for i in range(rows)],
+    }, schema=schema)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(expected)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    return table, expected
+
+
+def _plans(catalog, workers, cache):
+    jobs, details = [], []
+    for shard in range(workers):
+        table = catalog.get_table('default.blobs')
+        assert table.catalog_environment.blob_index_cache() is cache
+        assert not table.options.native_read_enabled()
+        builder = table.new_read_builder()
+        splits = builder.new_scan().with_shard(shard, workers).plan().splits()
+        assert len(splits) == 1
+        files = [f for f in splits[0].files if f.file_name.endswith('.blob')]
+        assert len(files) == 1
+        ranges = [(r.from_, r.to) for r in splits[0].row_ranges()]
+        details.append({'shard': shard, 'blob_file': files[0].file_name,
+                        'row_ranges': ranges})
+        jobs.append((builder, splits))
+    assert len({detail['blob_file'] for detail in details}) == 1
+    return jobs, details
+
+
+def _read_jobs(jobs, concurrent, parallelism):
+    barrier = threading.Barrier(len(jobs), timeout=60) if concurrent else None
+
+    def read(job):
+        builder, splits = job
+        reader = builder.new_read()
+        if barrier is not None:
+            barrier.wait()
+        return reader.to_arrow(splits, parallelism=parallelism)
+
+    if concurrent:
+        with ThreadPoolExecutor(max_workers=len(jobs)) as executor:
+            return list(executor.map(read, jobs))
+    return [read(job) for job in jobs]
+
+
+def _measure(name, jobs, concurrent, parallelism, cache, file_io,
+             expected, warm, instrumented):
+    cache.clear()
+    if warm:
+        _read_jobs(jobs, False, parallelism)
+        assert len(cache) == 1
+    probe = _IndexProbe(file_io, cache)
+    with ExitStack() as stack:
+        if instrumented:
+            probe.install(stack)
+        cpu_start, wall_start = time.process_time(), time.perf_counter()
+        results = _read_jobs(jobs, concurrent, parallelism)
+        wall_ms = (time.perf_counter() - wall_start) * 1000
+        cpu_ms = (time.process_time() - cpu_start) * 1000
+    # Validate every ID and payload, including shard order, outside the timer.
+    assert pa.concat_tables(results).equals(expected)
+    assert len(cache) == 1
+    totals = Counter()
+    for counters in probe.files.values():
+        totals.update(counters)
+    if instrumented:
+        assert len(probe.files) == 1
+        assert totals['reader_entries'] == len(jobs)
+        assert totals['index_read_calls'] == 2 * totals['decodes']
+        assert totals['index_read_bytes'] == (
+            totals['decoded_bytes'] + 5 * totals['decodes'])
+        if warm:
+            assert totals['decodes'] == 0
+        elif not concurrent:
+            assert totals['decodes'] == 1
+        else:
+            assert 1 <= totals['decodes'] <= len(jobs)
+    return {
+        'scenario': name, 'warm': warm, 'instrumented': instrumented,
+        'wall_ms': wall_ms, 'process_cpu_ms': cpu_ms,
+        'cache_bytes_after': cache.size_bytes,
+        'files': {Path(path).name: dict(counts)
+                  for path, counts in probe.files.items()},
+    }
+
+
+def run(rows, workers, repeats):
+    with tempfile.TemporaryDirectory(prefix='blob-cold-load-') as root:
+        catalog = CatalogFactory.create({'warehouse': root})
+        table, expected = _create_table(catalog, rows)
+        cache = table.catalog_environment.blob_index_cache()
+        shard_jobs, details = _plans(catalog, workers, cache)
+        builder = table.new_read_builder()
+        full_splits = builder.new_scan().plan().splits()
+        assert len(full_splits) == 1
+        full_jobs = [(builder, full_splits)]
+        assert _read_jobs(full_jobs, False, workers)[0].equals(expected)
+        scenarios = [
+            ('single_query', full_jobs, False, workers),
+            ('serial_shards', shard_jobs, False, 1),
+            ('concurrent_shards', shard_jobs, True, 1),
+        ]
+        runs = []
+        rng = random.Random(10151)
+        for instrumented in (True, False):
+            for repeat in range(repeats):
+                cases = [(scenario, warm) for scenario in scenarios
+                         for warm in (False, True)]
+                rng.shuffle(cases)
+                for (name, jobs, concurrent, parallelism), warm in cases:
+                    result = _measure(
+                        name, jobs, concurrent, parallelism, cache,
+                        table.file_io, expected, warm, instrumented)
+                    result['repeat'] = repeat
+                    runs.append(result)
+                print('instrumented=%s repeat=%d/%d passed' % (
+                    instrumented, repeat + 1, repeats), file=sys.stderr)
+        return {
+            'python': sys.version, 'pyarrow': pa.__version__,
+            'platform': platform.platform(), 'rows': rows,
+            'workers': workers, 'repeats': repeats,
+            'native_read_enabled': table.options.native_read_enabled(),
+            'cache_budget_bytes': cache.max_size_bytes,
+            'shards': details, 'runs': runs,
+        }
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('--rows', type=int, default=200000)
+    parser.add_argument('--workers', type=int, default=8)
+    parser.add_argument('--repeats', type=int, default=5)
+    parser.add_argument('--output', type=Path, required=True)
+    args = parser.parse_args()
+    if args.workers < 2 or args.rows < args.workers or args.repeats < 1:
+        parser.error('Require workers >= 2, rows >= workers, repeats >= 1')
+    result = run(args.rows, args.workers, args.repeats)
+    args.output.write_text(json.dumps(result, indent=2) + '\n', 
encoding='utf-8')
+    print(str(args.output))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/paimon-python/pypaimon/common/blob_index_cache.py 
b/paimon-python/pypaimon/common/blob_index_cache.py
index 611ed14312..acf3c47b9e 100644
--- a/paimon-python/pypaimon/common/blob_index_cache.py
+++ b/paimon-python/pypaimon/common/blob_index_cache.py
@@ -15,9 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import os
 import sys
 from collections import namedtuple
+from concurrent.futures import Future
 from threading import Lock
+from types import SimpleNamespace
 
 from cachetools import LRUCache
 
@@ -40,17 +43,27 @@ class BlobIndexCache:
         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()
+        self._states = {}
+
+    def _state(self):
+        pid = os.getpid()
+        states = self._states
+        state = states.get(pid)
+        if state is None:
+            # Publish a complete state atomically; never touch inherited locks 
or Futures.
+            state = states.setdefault(pid, SimpleNamespace(
+                lock=Lock(), loading={}, cache=LRUCache(
+                    maxsize=self.max_size_bytes,
+                    getsizeof=lambda entry: entry.size_bytes)))
+            self._states = {pid: state}
+        return state
 
     def get(self, file_path):
         if self.max_size_bytes == 0:
             return None
-        with self._lock:
-            entry = self._cache.get(file_path)
+        state = self._state()
+        with state.lock:
+            entry = state.cache.get(file_path)
         if entry is None:
             return None
         return entry.blob_lengths, entry.blob_offsets
@@ -69,25 +82,61 @@ class BlobIndexCache:
         if size_bytes > self.max_size_bytes:
             return
         entry = _CachedBlobIndex(blob_lengths, blob_offsets, size_bytes)
-        with self._lock:
-            self._cache[file_path] = entry
+        state = self._state()
+        with state.lock:
+            state.cache[file_path] = entry
+
+    def get_or_load(self, file_path, loader):
+        """Share an in-flight load without holding the cache lock during 
I/O."""
+        if self.max_size_bytes == 0:
+            return loader()
+        state = self._state()
+        with state.lock:
+            entry = state.cache.get(file_path)
+            if entry is not None:
+                return entry.blob_lengths, entry.blob_offsets
+            pending = state.loading.get(file_path)
+            owner = pending is None
+            if owner:
+                pending = Future()
+                state.loading[file_path] = pending
+
+        if not owner:
+            return pending.result()
+        try:
+            index = loader()
+            self.put(file_path, *index)
+        except BaseException as error:
+            # Unblock waiters even if the loading thread is interrupted.
+            pending.set_exception(error)
+            raise
+        else:
+            pending.set_result(index)
+            return index
+        finally:
+            with state.lock:
+                del state.loading[file_path]
 
     def __len__(self):
-        with self._lock:
-            return len(self._cache)
+        state = self._state()
+        with state.lock:
+            return len(state.cache)
 
     def __contains__(self, file_path):
-        with self._lock:
-            return file_path in self._cache
+        state = self._state()
+        with state.lock:
+            return file_path in state.cache
 
     def clear(self):
-        with self._lock:
-            self._cache.clear()
+        state = self._state()
+        with state.lock:
+            state.cache.clear()
 
     @property
     def size_bytes(self):
-        with self._lock:
-            return self._cache.currsize
+        state = self._state()
+        with state.lock:
+            return state.cache.currsize
 
     def __reduce__(self):
         return type(self), (MemorySize.of_bytes(self.max_size_bytes),)
diff --git a/paimon-python/pypaimon/read/reader/format_blob_reader.py 
b/paimon-python/pypaimon/read/reader/format_blob_reader.py
index c959228cff..c95626d7fe 100644
--- a/paimon-python/pypaimon/read/reader/format_blob_reader.py
+++ b/paimon-python/pypaimon/read/reader/format_blob_reader.py
@@ -374,13 +374,12 @@ class FormatBlobReader(RecordBatchReader):
             )
             return
 
-        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)
-            self.blob_offsets = list(blob_offsets)
-            return
+        blob_lengths, blob_offsets = self._index_cache.get_or_load(
+            self.file_path, self._load_index)
+        self.blob_lengths = list(blob_lengths)
+        self.blob_offsets = list(blob_offsets)
 
+    def _load_index(self):
         self._input_stream = self._file_io.new_input_stream(self.file_path)
         f = self._input_stream
 
@@ -405,10 +404,7 @@ class FormatBlobReader(RecordBatchReader):
         if len(index_bytes) != index_length:
             raise IOError("Invalid blob file: cannot read index")
 
-        blob_lengths, blob_offsets = _decode_blob_index(index_bytes)
-        self._index_cache.put(self.file_path, blob_lengths, blob_offsets)
-        self.blob_lengths = list(blob_lengths)
-        self.blob_offsets = list(blob_offsets)
+        return _decode_blob_index(index_bytes)
 
     def _apply_row_indices(self, row_indices: Optional[Any]) -> None:
         if row_indices is None:
diff --git a/paimon-python/pypaimon/tests/blob_index_cache_test.py 
b/paimon-python/pypaimon/tests/blob_index_cache_test.py
new file mode 100644
index 0000000000..bb6f07b90b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/blob_index_cache_test.py
@@ -0,0 +1,353 @@
+# 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 multiprocessing
+import os
+import pickle
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.blob_index_cache import BlobIndexCache
+from pypaimon.common.memory_size import MemorySize
+from pypaimon.common.options import Options
+from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.read.reader import format_blob_reader
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
[email protected]('budget', [1, 1024 * 1024])
[email protected]('error_type', [None, IOError, KeyboardInterrupt])
+def test_concurrent_load_result_and_failure_are_shared(budget, error_type):
+    cache = BlobIndexCache(MemorySize.of_bytes(budget))
+    entered, release, waiting = (threading.Event() for _ in range(3))
+    index = ((3,), (0,))
+
+    def load():
+        entered.set()
+        assert release.wait(10)
+        if error_type is not None:
+            raise error_type('index load failed')
+        return index
+
+    loader = Mock(side_effect=load)
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        owner = executor.submit(cache.get_or_load, 'file.blob', loader)
+        try:
+            assert entered.wait(10)
+            pending = cache._state().loading['file.blob']
+            original_result = pending.result
+
+            def wait_for_result():
+                waiting.set()
+                return original_result()
+
+            with patch.object(pending, 'result', wait_for_result):
+                waiter = executor.submit(cache.get_or_load, 'file.blob', 
loader)
+                assert waiting.wait(10)
+                release.set()
+                for future in (owner, waiter):
+                    if error_type is None:
+                        assert future.result(timeout=10) is index
+                    else:
+                        with pytest.raises(error_type, match='index load 
failed'):
+                            future.result(timeout=10)
+        finally:
+            release.set()
+    assert loader.call_count == 1
+    assert not cache._state().loading
+    retained = error_type is None and budget > 1
+    assert len(cache) == int(retained)
+    retry = Mock(return_value=index)
+    assert cache.get_or_load('file.blob', retry) == index
+    assert retry.call_count == int(not retained)
+
+
[email protected]('separate_caches', [False, True])
+def test_other_files_and_catalogs_do_not_wait(separate_caches):
+    first = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+    second = BlobIndexCache(MemorySize.of_mebi_bytes(1)) if separate_caches 
else first
+    second_path = 'a.blob' if separate_caches else 'b.blob'
+    both_loading = threading.Barrier(2, timeout=10)
+
+    def load():
+        both_loading.wait()
+        return ((1,), (0,))
+
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        futures = [executor.submit(first.get_or_load, 'a.blob', load),
+                   executor.submit(second.get_or_load, second_path, load)]
+        for future in futures:
+            assert future.result(timeout=10) == ((1,), (0,))
+
+
+def test_disabled_cache_does_not_share_or_retain_loads():
+    cache = BlobIndexCache(MemorySize.of_bytes(0))
+    both_loading = threading.Barrier(2, timeout=10)
+
+    def load():
+        both_loading.wait()
+        return ((1,), (0,))
+
+    loader = Mock(side_effect=load)
+    with ThreadPoolExecutor(max_workers=2) as executor:
+        futures = [executor.submit(cache.get_or_load, 'a.blob', loader) for _ 
in range(2)]
+        for future in futures:
+            assert future.result(timeout=10) == ((1,), (0,))
+    assert loader.call_count == 2
+    assert len(cache) == 0
+    assert not cache._state().loading
+
+
+def test_hot_cache_does_not_create_a_future():
+    cache = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+    cache.put('a.blob', (1,), (0,))
+    loader = Mock(side_effect=AssertionError('hot cache invoked loader'))
+    with patch('pypaimon.common.blob_index_cache.Future',
+               side_effect=AssertionError('hot cache created future')):
+        assert cache.get_or_load('a.blob', loader) == ((1,), (0,))
+
+
+def test_serialization_does_not_copy_inflight_loads():
+    cache = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+    entered, release = threading.Event(), threading.Event()
+
+    def load():
+        entered.set()
+        assert release.wait(10)
+        return ((1,), (0,))
+
+    with ThreadPoolExecutor(max_workers=1) as executor:
+        owner = executor.submit(cache.get_or_load, 'a.blob', load)
+        try:
+            assert entered.wait(10)
+            restored = pickle.loads(pickle.dumps(cache))
+            assert restored.max_size_bytes == cache.max_size_bytes
+            assert len(restored) == 0
+            assert not restored._state().loading
+            assert restored.get_or_load('a.blob', lambda: ((2,), (0,))) == 
((2,), (0,))
+        finally:
+            release.set()
+        assert owner.result(timeout=10) == ((1,), (0,))
+    assert cache.get('a.blob') == ((1,), (0,))
+
+
[email protected]
+def fork_context():
+    if 'fork' not in multiprocessing.get_all_start_methods():
+        pytest.skip('fork required')
+    return multiprocessing.get_context('fork')
+
+
+def test_forked_blob_reader_does_not_wait_for_parent_load(tmp_path, 
fork_context):
+    cache = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+    file_io = LocalFileIO(str(tmp_path), Options({}))
+    path = str(tmp_path / 'payload.blob')
+    expected = [b'first', None, b'last']
+    file_io.write_blob(path, pa.table({'payload': expected}, schema=pa.schema([
+        ('payload', pa.large_binary())])))
+    field = DataField(0, 'payload', AtomicType('BLOB'))
+    entered, release = threading.Event(), threading.Event()
+    parent_pid = os.getpid()
+    original_decode = format_blob_reader._decode_blob_index
+
+    def decode(data):
+        if os.getpid() == parent_pid:
+            entered.set()
+            assert release.wait(10)
+        return original_decode(data)
+
+    def read():
+        reader = format_blob_reader.FormatBlobReader(
+            file_io, path, [field.name], [field], None, False, 
index_cache=cache)
+        try:
+            return reader.read_arrow_batch().column(0).to_pylist()
+        finally:
+            reader.close()
+
+    def child(connection):
+        try:
+            # The parent finishes first, but its Future remains incomplete 
here.
+            connection.recv()
+            with patch.object(format_blob_reader, '_decode_blob_index',
+                              wraps=original_decode) as child_decode:
+                values = [read(), read()]
+                connection.send((values, child_decode.call_count, len(cache)))
+        except BaseException as error:
+            connection.send(('error', repr(error)))
+        finally:
+            connection.close()
+
+    receiving, sending = fork_context.Pipe()
+    process = fork_context.Process(target=child, args=(sending,))
+    with patch.object(format_blob_reader, '_decode_blob_index', 
side_effect=decode), \
+            ThreadPoolExecutor(max_workers=1) as executor:
+        owner = executor.submit(read)
+        try:
+            assert entered.wait(5)
+            assert cache._state().lock.acquire(blocking=False)
+            cache._state().lock.release()
+            process.start()
+            sending.close()
+            release.set()
+            assert owner.result(timeout=5) == expected
+            assert not cache._state().loading
+            receiving.send('parent finished')
+            assert receiving.poll(5), 'child waited for an inherited Future'
+            assert receiving.recv() == ([expected, expected], 1, 1)
+            process.join(5)
+            assert process.exitcode == 0
+            assert len(cache) == 1
+        finally:
+            release.set()
+            if process.is_alive():
+                process.terminate()
+                process.join(5)
+            receiving.close()
+            sending.close()
+
+
+def test_forked_cache_concurrent_first_access_avoids_parent_lock(fork_context):
+    cache = BlobIndexCache(MemorySize.of_mebi_bytes(1))
+    cache.put('parent.blob', (1,), (0,))
+
+    def child(connection):
+        creating, resume = threading.Event(), threading.Event()
+        results = []
+        loader = Mock(return_value=((2,), (0,)))
+        original_lock = threading.Lock
+
+        def new_lock():
+            if threading.current_thread().name == 'first':
+                creating.set()
+                assert resume.wait(5)
+            return original_lock()
+
+        def access():
+            results.append(cache.get_or_load('child.blob', loader))
+
+        try:
+            with patch('pypaimon.common.blob_index_cache.Lock', 
side_effect=new_lock):
+                first = threading.Thread(target=access, name='first', 
daemon=True)
+                second = threading.Thread(target=access, name='second', 
daemon=True)
+                first.start()
+                assert creating.wait(3), 'child reused the parent lock'
+                second.start()
+                second.join(3)
+                assert not second.is_alive()
+                resume.set()
+                first.join(3)
+                assert not first.is_alive()
+            connection.send((results, loader.call_count, len(cache),
+                             cache.get('parent.blob'), cache.size_bytes > 0))
+            cache.clear()
+            assert len(cache) == 0 and 'child.blob' not in cache
+        except BaseException as error:
+            connection.send(('error', repr(error)))
+        finally:
+            resume.set()
+            connection.close()
+
+    receiving, sending = fork_context.Pipe(duplex=False)
+    process = fork_context.Process(target=child, args=(sending,))
+    with cache._state().lock:
+        try:
+            process.start()
+            sending.close()
+            assert receiving.poll(10), 'child deadlocked on the inherited lock'
+            assert receiving.recv() == ([((2,), (0,))] * 2, 1, 1, None, True)
+            process.join(5)
+            assert process.exitcode == 0
+        finally:
+            if process.is_alive():
+                process.terminate()
+                process.join(5)
+            receiving.close()
+            sending.close()
+    assert cache.get('parent.blob') == ((1,), (0,))
+    assert cache.get('child.blob') is None
+
+
[email protected]_plan
[email protected]_read
[email protected]('blob_parallelism', [1, 4])
+def test_public_shard_reads_share_index_and_keep_payloads(tmp_path, 
blob_parallelism):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', False)
+    schema = pa.schema([('id', pa.int64()), ('payload', pa.large_binary())])
+    catalog.create_table('default.blobs', Schema.from_pyarrow_schema(schema, 
options={
+        'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+    }), False)
+    table = catalog.get_table('default.blobs')
+    expected = pa.table({'id': list(range(1000)),
+                         'payload': [('value-%d' % i).encode() for i in 
range(1000)]},
+                        schema=schema)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(expected)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+
+    cache = table.catalog_environment.blob_index_cache()
+    jobs = []
+    for shard in range(4):
+        shard_table = catalog.get_table('default.blobs')
+        assert shard_table.catalog_environment.blob_index_cache() is cache
+        builder = shard_table.new_read_builder()
+        splits = builder.new_scan().with_shard(shard, 4).plan().splits()
+        assert len(splits) == 1
+        jobs.append((builder, splits))
+    paths = [file.physical_path() for _, splits in jobs
+             for file in splits[0].files if file.file_name.endswith('.blob')]
+    assert len(paths) == 4 and len(set(paths)) == 1
+
+    start = threading.Barrier(4, timeout=10)
+    streams = []
+    stream_lock = threading.Lock()
+    original_open = LocalFileIO.new_input_stream
+
+    def open_stream(file_io, path):
+        stream = original_open(file_io, path)
+        if str(path).endswith('.blob'):
+            with stream_lock:
+                streams.append(stream)
+        return stream
+
+    def read(job):
+        builder, splits = job
+        start.wait()
+        return builder.new_read().to_arrow(
+            splits, parallelism=1, blob_parallelism=blob_parallelism)
+
+    with patch.object(LocalFileIO, 'new_input_stream', open_stream), 
patch.object(
+            format_blob_reader, '_decode_blob_index',
+            wraps=format_blob_reader._decode_blob_index) as decode:
+        for _ in range(2):
+            with ThreadPoolExecutor(max_workers=4) as executor:
+                results = list(executor.map(read, jobs))
+            assert pa.concat_tables(results).equals(expected)
+            assert decode.call_count == 1
+            assert all(stream.closed for stream in streams)
+    assert len(cache) == 1
+    assert not cache._state().loading

Reply via email to