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 219f9ff9fe [python] Fix local cache being disabled in spawned workers
(#10479)
219f9ff9fe is described below
commit 219f9ff9fe861759689978ccc1e0d6d4c94e12e6
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 11 22:58:25 2026 +0800
[python] Fix local cache being disabled in spawned workers (#10479)
---
docs/docs/program-api/file-cache.mdx | 7 +-
.../pypaimon/filesystem/caching_file_io.py | 78 ++++++-
.../pypaimon/tests/caching_file_io_test.py | 252 ++++++++++++++++++++-
3 files changed, 320 insertions(+), 17 deletions(-)
diff --git a/docs/docs/program-api/file-cache.mdx
b/docs/docs/program-api/file-cache.mdx
index 6d4b2fd24a..67320f7a17 100644
--- a/docs/docs/program-api/file-cache.mdx
+++ b/docs/docs/program-api/file-cache.mdx
@@ -167,6 +167,7 @@ reference to the shared manager; the last reference
releases the shared entry. A
### PyPaimon workers
-PyPaimon's cache is removed when `CachingFileIO` is pickled. An unpickled copy
reads directly from
-storage. Create a catalog with cache options on each Python worker when you
need caching there.
-The Java worker lifecycle above does not apply to Python serialization.
+With `spawn`, PyPaimon preserves cache configuration when `CachingFileIO` is
pickled. Workers reuse
+process-local managers across tasks; memory entries are not copied from the
parent, and disk caches
+scan their directory on first restore. With `fork`, create the catalog after
forking to avoid
+inheriting cache locks.
diff --git a/paimon-python/pypaimon/filesystem/caching_file_io.py
b/paimon-python/pypaimon/filesystem/caching_file_io.py
index 1ddbb76ee0..123e805429 100644
--- a/paimon-python/pypaimon/filesystem/caching_file_io.py
+++ b/paimon-python/pypaimon/filesystem/caching_file_io.py
@@ -28,17 +28,76 @@ the delegate FileIO.
import hashlib
import os
import threading
+import uuid
+import weakref
from collections import OrderedDict
+from concurrent.futures import Future
from typing import Optional
from pypaimon.common.file_io import FileIO, supports_pread, pread
from pypaimon.utils.file_type import FileType
+class _WorkerCacheRegistry:
+ """Share active managers and retain a few after their task completes."""
+
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._active = weakref.WeakValueDictionary()
+ self._recent = OrderedDict()
+ self._pending = {}
+
+ def _remember(self, key, manager):
+ self._recent[key] = manager
+ self._recent.move_to_end(key)
+ if len(self._recent) > 8:
+ self._recent.popitem(last=False)
+
+ def restore(self, identity, cache_type, args):
+ key = (os.getpid(), identity, cache_type, args)
+ with self._lock:
+ manager = self._active.get(key)
+ if manager is not None:
+ self._remember(key, manager)
+ return manager
+ pending = self._pending.get(key)
+ create = pending is None
+ if create:
+ pending = Future()
+ self._pending[key] = pending
+
+ if not create:
+ return pending.result()
+
+ try:
+ manager = cache_type(*args)
+ manager._identity = identity
+ except BaseException as error:
+ with self._lock:
+ del self._pending[key]
+ pending.set_exception(error)
+ raise
+
+ with self._lock:
+ self._active[key] = manager
+ self._remember(key, manager)
+ del self._pending[key]
+ pending.set_result(manager)
+ return manager
+
+
+_WORKER_CACHE_REGISTRY = _WorkerCacheRegistry()
+
+
+def _restore_cache_manager(identity, cache_type, args):
+ return _WORKER_CACHE_REGISTRY.restore(identity, cache_type, args)
+
+
class LocalMemoryCacheManager:
"""Block-level in-memory cache with LRU eviction."""
def __init__(self, max_size_bytes: int, block_size: int = 1 * 1024 * 1024):
+ self._identity = uuid.uuid4().hex
self._max_size_bytes = max_size_bytes
self._block_size = block_size
self._lock = threading.Lock()
@@ -46,6 +105,11 @@ class LocalMemoryCacheManager:
self._cache: OrderedDict = OrderedDict()
self._file_size_cache: dict = {}
+ def __reduce__(self):
+ # Rebuild process-local state without serializing cached blocks or
locks.
+ return (_restore_cache_manager,
+ (self._identity, type(self), (self._max_size_bytes,
self._block_size)))
+
@property
def block_size(self) -> int:
return self._block_size
@@ -83,6 +147,7 @@ class LocalDiskCacheManager:
def __init__(self, cache_dir: str, max_size_bytes: int,
block_size: int = 1 * 1024 * 1024):
+ self._identity = uuid.uuid4().hex
self._cache_dir = cache_dir
self._max_size_bytes = max_size_bytes
self._block_size = block_size
@@ -94,6 +159,11 @@ class LocalDiskCacheManager:
os.makedirs(cache_dir, exist_ok=True)
self._current_size = self._scan_and_populate_index()
+ def __reduce__(self):
+ return (_restore_cache_manager,
+ (self._identity, type(self),
+ (self._cache_dir, self._max_size_bytes, self._block_size)))
+
@property
def block_size(self) -> int:
return self._block_size
@@ -329,8 +399,7 @@ class CachingFileIO(FileIO):
"""FileIO wrapper that caches reads at block granularity.
Only file types in the whitelist are cached. Others are read directly
- from the delegate. After pickling/unpickling, the cache is None and reads
- fall through to the delegate directly.
+ from the delegate. Cache managers are restored process-locally when
unpickled.
"""
def __init__(self, delegate: FileIO, cache, whitelist=None):
@@ -342,9 +411,8 @@ class CachingFileIO(FileIO):
self._whitelist = whitelist
def __getstate__(self):
- state = self.__dict__.copy()
- state['_cache'] = None
- return state
+ # Python 3.10 otherwise finds the delegate's hook through __getattr__.
+ return self.__dict__.copy()
# Fallback caps when local-cache.max-size is unset (memory shares the
heap).
_DEFAULT_MEMORY_CACHE_MAX_SIZE = 256 * 1024 * 1024
diff --git a/paimon-python/pypaimon/tests/caching_file_io_test.py
b/paimon-python/pypaimon/tests/caching_file_io_test.py
index 940a4f4ab2..88edfbcf4b 100644
--- a/paimon-python/pypaimon/tests/caching_file_io_test.py
+++ b/paimon-python/pypaimon/tests/caching_file_io_test.py
@@ -18,20 +18,66 @@
"""Tests for LocalDiskCacheManager, CachingInputStream, and CachingFileIO."""
+import gc
import io
+import multiprocessing
import os
import pickle
import shutil
import tempfile
import threading
import unittest
-from unittest.mock import MagicMock
+from concurrent.futures import ThreadPoolExecutor
+from unittest.mock import MagicMock, patch
from pypaimon.filesystem.caching_file_io import (
LocalDiskCacheManager,
+ LocalMemoryCacheManager,
CachingFileIO,
CachingInputStream,
)
+from pypaimon.filesystem.local_file_io import LocalFileIO
+
+
+class _CountingLocalFileIO(LocalFileIO):
+
+ def __init__(self):
+ super().__init__()
+ self.opens = 0
+
+ def new_input_stream(self, path):
+ self.opens += 1
+ return super().new_input_stream(path)
+
+
+def _read_cached_twice(file_io, path, connection):
+ values = []
+ for _ in range(2):
+ with file_io.new_input_stream(path) as stream:
+ values.append(stream.read())
+ connection.send((values, file_io._delegate.opens))
+ connection.close()
+
+
+def _read_independent_pickles(payload, path, connection):
+ first = pickle.loads(payload)
+ second = pickle.loads(payload)
+ values = []
+ for file_io in (first, second):
+ with file_io.new_input_stream(path) as stream:
+ values.append(stream.read())
+ connection.send((values, first._delegate.opens, second._delegate.opens,
+ first._cache is second._cache))
+ connection.close()
+
+
+def _read_rest_cached_twice(file_io, path, connection):
+ values = []
+ for _ in range(2):
+ with file_io.new_input_stream(path) as stream:
+ values.append(stream.read())
+ connection.send((values, file_io._cache.get_block(path, 0)))
+ connection.close()
class LocalDiskCacheManagerTest(unittest.TestCase):
@@ -386,20 +432,208 @@ class CachingFileIOTest(unittest.TestCase):
caching_io.new_output_stream("/out")
delegate.new_output_stream.assert_called_once_with("/out")
- def test_pickle_drops_cache(self):
- from pypaimon.filesystem.local_file_io import LocalFileIO
+ def test_pickle_preserves_memory_cache_configuration(self):
from pypaimon.utils.file_type import FileType
- cache = LocalDiskCacheManager(
- self.cache_dir, 2 ** 63 - 1, block_size=64)
+ cache = LocalMemoryCacheManager(128, block_size=64)
+ cache.put_block("snapshot-1", 0, b"parent data")
+ cache.put_file_size("snapshot-1", 11)
caching_io = CachingFileIO(
- LocalFileIO(), cache, {FileType.META, FileType.DATA})
-
+ _CountingLocalFileIO(), cache, {FileType.META})
restored = pickle.loads(pickle.dumps(caching_io))
+ self.assertIsInstance(restored._cache, LocalMemoryCacheManager)
+ self.assertEqual(128, restored._cache._max_size_bytes)
+ self.assertEqual(64, restored._cache.block_size)
+ self.assertIsNone(restored._cache.get_block("snapshot-1", 0))
+ self.assertEqual(-1, restored._cache.get_file_size("snapshot-1"))
+ self.assertEqual({FileType.META}, restored._whitelist)
+ self.assertEqual(b"parent data", cache.get_block("snapshot-1", 0))
+ self.assertIsNot(cache._lock, restored._cache._lock)
+
+ def test_pickle_preserves_disk_cache_configuration(self):
+ cache = LocalDiskCacheManager(self.cache_dir, 128, block_size=64)
+ cache.put_block("snapshot-1", 0, b"persisted block")
+ restored = pickle.loads(pickle.dumps(
+ CachingFileIO(_CountingLocalFileIO(), cache)))
+ self.assertIsInstance(restored._cache, LocalDiskCacheManager)
+ self.assertEqual(self.cache_dir, restored._cache._cache_dir)
+ self.assertEqual(128, restored._cache._max_size_bytes)
+ self.assertEqual(64, restored._cache.block_size)
+ self.assertEqual(b"persisted block",
+ restored._cache.get_block("snapshot-1", 0))
+ self.assertIsNot(cache._lock, restored._cache._lock)
+
+ def test_pickle_preserves_shared_cache(self):
+ cache = LocalMemoryCacheManager(128, 64)
+ first, second = pickle.loads(pickle.dumps((
+ CachingFileIO(_CountingLocalFileIO(), cache),
+ CachingFileIO(_CountingLocalFileIO(), cache))))
+ self.assertIs(first._cache, second._cache)
+ first._cache.put_block("snapshot-1", 0, b"worker block")
+ self.assertEqual(b"worker block",
+ second._cache.get_block("snapshot-1", 0))
+
+ def test_independent_pickles_reuse_worker_cache(self):
+ for disk in (False, True):
+ with self.subTest(disk=disk):
+ cache = (LocalDiskCacheManager(
+ os.path.join(self.cache_dir, "blocks"), 128, 64)
+ if disk else LocalMemoryCacheManager(128, 64))
+ payload = pickle.dumps(CachingFileIO(_CountingLocalFileIO(),
cache))
+ first = pickle.loads(payload)
+ second = pickle.loads(payload)
+ self.assertIs(first._cache, second._cache)
+ self.assertIsNot(cache, first._cache)
+ first._cache.put_block("snapshot-1", 0, b"worker block")
+ self.assertEqual(b"worker block",
+ second._cache.get_block("snapshot-1", 0))
+
+ def test_separate_memory_cache_identities_remain_isolated(self):
+ first = pickle.loads(pickle.dumps(LocalMemoryCacheManager(128, 64)))
+ second = pickle.loads(pickle.dumps(LocalMemoryCacheManager(128, 64)))
+ self.assertIsNot(first, second)
+ first.put_block("snapshot-1", 0, b"first catalog")
+ self.assertIsNone(second.get_block("snapshot-1", 0))
+
+ def test_active_manager_survives_recent_cache_eviction(self):
+ payload = pickle.dumps(LocalMemoryCacheManager(128, 64))
+ first = pickle.loads(payload)
+ first.put_block("snapshot-1", 0, b"worker block")
+ for _ in range(9):
+ pickle.loads(pickle.dumps(LocalMemoryCacheManager(128, 64)))
+ second = pickle.loads(payload)
+ self.assertIs(first, second)
+ self.assertEqual(b"worker block", second.get_block("snapshot-1", 0))
+
+ def test_recent_manager_survives_task_teardown(self):
+ payload = pickle.dumps(LocalMemoryCacheManager(128, 64))
+ first = pickle.loads(payload)
+ first.put_block("snapshot-1", 0, b"worker block")
+ del first
+ gc.collect()
+ second = pickle.loads(payload)
+ self.assertEqual(b"worker block", second.get_block("snapshot-1", 0))
+
+ def test_concurrent_restore_creates_one_manager(self):
+ payload = pickle.dumps(LocalMemoryCacheManager(128, 64))
+ barrier = threading.Barrier(8)
+ constructor_calls = []
+ original_init = LocalMemoryCacheManager.__init__
+
+ def slow_init(manager, *args, **kwargs):
+ constructor_calls.append(1)
+ threading.Event().wait(0.1)
+ original_init(manager, *args, **kwargs)
+
+ def restore():
+ barrier.wait(5)
+ return pickle.loads(payload)
+
+ with patch.object(LocalMemoryCacheManager, "__init__", slow_init):
+ with ThreadPoolExecutor(max_workers=8) as pool:
+ futures = [pool.submit(restore) for _ in range(8)]
+ restored = [future.result(timeout=10) for future in futures]
+ self.assertEqual(1, len(constructor_calls))
+ self.assertTrue(all(manager is restored[0] for manager in restored))
+
+ def test_pickle_disabled_cache(self):
+ restored = pickle.loads(pickle.dumps(
+ CachingFileIO(_CountingLocalFileIO(), None)))
self.assertIsNone(restored._cache)
- self.assertEqual({FileType.META, FileType.DATA}, restored._whitelist)
- self.assertIsInstance(restored._delegate, LocalFileIO)
+
+ def test_spawn_worker_reuses_cached_blocks(self):
+ path = os.path.join(self.cache_dir, "snapshot-1")
+ with open(path, "wb") as output:
+ output.write(b"cached data")
+ for disk in (False, True):
+ with self.subTest(disk=disk):
+ cache = (LocalDiskCacheManager(
+ os.path.join(self.cache_dir, "blocks"), 128, 64)
+ if disk else LocalMemoryCacheManager(128, 64))
+ caching_io = CachingFileIO(_CountingLocalFileIO(), cache)
+ context = multiprocessing.get_context("spawn")
+ parent, child = context.Pipe()
+ process = context.Process(
+ target=_read_cached_twice, args=(caching_io, path, child))
+ process.start()
+ child.close()
+ try:
+ self.assertTrue(parent.poll(30), "worker timed out")
+ values, opens = parent.recv()
+ process.join(10)
+ self.assertEqual(0, process.exitcode)
+ self.assertEqual([b"cached data", b"cached data"], values)
+ self.assertEqual(1, opens)
+ finally:
+ parent.close()
+ if process.is_alive():
+ process.terminate()
+ process.join(10)
+
+ def test_spawn_worker_reuses_cache_across_independent_pickles(self):
+ path = os.path.join(self.cache_dir, "snapshot-1")
+ with open(path, "wb") as output:
+ output.write(b"cached data")
+ cache = LocalMemoryCacheManager(128, 64)
+ payload = pickle.dumps(CachingFileIO(_CountingLocalFileIO(), cache))
+ context = multiprocessing.get_context("spawn")
+ parent, child = context.Pipe()
+ process = context.Process(
+ target=_read_independent_pickles, args=(payload, path, child))
+ process.start()
+ child.close()
+ try:
+ self.assertTrue(parent.poll(30), "worker timed out")
+ values, first_opens, second_opens, shared = parent.recv()
+ process.join(10)
+ self.assertEqual(0, process.exitcode)
+ self.assertEqual([b"cached data", b"cached data"], values)
+ self.assertEqual((1, 0), (first_opens, second_opens))
+ self.assertTrue(shared)
+ finally:
+ parent.close()
+ if process.is_alive():
+ process.terminate()
+ process.join(10)
+
+ def test_spawn_rest_file_io_preserves_cache_wrapper(self):
+ from pypaimon.catalog.rest.rest_token import RESTToken
+ from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.options import Options
+
+ path = os.path.join(self.cache_dir, "snapshot-1")
+ with open(path, "wb") as output:
+ output.write(b"cached data")
+ delegate = RESTTokenFileIO(
+ Identifier.create("db", "t"), "file://" + self.cache_dir,
+ Options({"uri": "http://127.0.0.1:1", "token.provider": "bear",
+ "token": "local-test-token"}))
+ delegate.token = RESTToken({}, 2 ** 63 - 1)
+ file_io = CachingFileIO(delegate, LocalMemoryCacheManager(128, 64))
+ restored = pickle.loads(pickle.dumps(file_io))
+ self.assertIsInstance(restored._delegate, RESTTokenFileIO)
+ self.assertIsInstance(restored._cache, LocalMemoryCacheManager)
+
+ context = multiprocessing.get_context("spawn")
+ parent, child = context.Pipe()
+ process = context.Process(
+ target=_read_rest_cached_twice, args=(file_io, path, child))
+ process.start()
+ child.close()
+ try:
+ self.assertTrue(parent.poll(30), "worker timed out")
+ values, cached_block = parent.recv()
+ process.join(10)
+ self.assertEqual(0, process.exitcode)
+ self.assertEqual([b"cached data", b"cached data"], values)
+ self.assertEqual(b"cached data", cached_block)
+ finally:
+ parent.close()
+ if process.is_alive():
+ process.terminate()
+ process.join(10)
def test_to_filesystem_path_forwarded_to_delegate(self):
delegate = MagicMock()