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 8e9fee64f2 [python] Restore the process-wide FileIO cache of
RESTTokenFileIO (#10552)
8e9fee64f2 is described below
commit 8e9fee64f26e7138a2b2e64f499844e88c4b9b09
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Wed Oct 14 13:18:07 2026 +0800
[python] Restore the process-wide FileIO cache of RESTTokenFileIO (#10552)
---
.../pypaimon/catalog/rest/rest_token_file_io.py | 73 +++++----
.../filesystem/jindo_file_system_handler.py | 10 +-
.../pypaimon/tests/jindo_file_system_test.py | 71 ++++++++-
.../pypaimon/tests/rest/rest_token_file_io_test.py | 174 ++++++++++++++++++++-
4 files changed, 283 insertions(+), 45 deletions(-)
diff --git a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
index 861e3d3bb1..1f83adeb31 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
@@ -40,8 +40,14 @@ class RESTTokenFileIO(FileIO):
A FileIO to support getting token from REST Server.
"""
+ _FILE_IO_CACHE_MAXSIZE = 1000
_FILE_IO_CACHE_TTL = 36000 # 10 hours in seconds
+ # Process-wide like Java's FILE_IO_CACHE, so a token refresh keeps the
FileIO
+ # that streams opened with the previous token still use.
+ _FILE_IO_CACHE = TTLCache(maxsize=_FILE_IO_CACHE_MAXSIZE,
ttl=_FILE_IO_CACHE_TTL)
+ _FILE_IO_CACHE_LOCK = threading.Lock()
+
_TOKEN_CACHE: dict = {}
_TOKEN_LOCKS: dict = {}
_TOKEN_LOCKS_LOCK = threading.Lock()
@@ -62,20 +68,16 @@ class RESTTokenFileIO(FileIO):
self.api_instance: Optional[RESTApi] = None
self.log = logging.getLogger(__name__)
self._uri_reader_factory_cache: Optional[UriReaderFactory] = None
- self._init_file_io_cache()
-
- def _init_file_io_cache(self):
- # FileIO is bound to this instance's path and catalog options.
- self._file_io_cache = TTLCache(maxsize=1, ttl=self._FILE_IO_CACHE_TTL)
- self._file_io_cache_lock = threading.Lock()
+ self._file_io_scope: Optional[str] = None
+ self._token_lock = threading.Lock()
def __getstate__(self):
state = self.__dict__.copy()
# Remove non-serializable objects
state.pop('api_instance', None)
state.pop('_uri_reader_factory_cache', None)
- state.pop('_file_io_cache', None)
- state.pop('_file_io_cache_lock', None)
+ state.pop('_file_io_scope', None)
+ state.pop('_token_lock', None)
# token can be serialized, but we'll refresh it on deserialization
return state
@@ -84,13 +86,14 @@ class RESTTokenFileIO(FileIO):
self._uri_reader_factory_cache = None
# api_instance will be recreated when needed
self.api_instance = None
- self._init_file_io_cache()
+ self._file_io_scope = None
+ self._token_lock = threading.Lock()
def file_io(self) -> FileIO:
return self._file_io_with_token()[0]
def _file_io_with_token(self, minimum_validity_millis: int = 0):
- with self._file_io_cache_lock:
+ with self._token_lock:
if minimum_validity_millis:
self.try_to_refresh_token(minimum_validity_millis)
if not self._has_remaining_lifetime(
@@ -101,29 +104,39 @@ class RESTTokenFileIO(FileIO):
else:
self.try_to_refresh_token()
- if self.token is None:
+ token = self.token
+ if token is None:
return (FileIO.get(
self.path, self.catalog_options or Options({})), None)
- cache_key = self.token
- cache = self._file_io_cache
- file_io = cache.get(cache_key)
- if file_io is not None:
- return file_io, cache_key
-
- merged_properties = RESTUtil.merge(
- self.catalog_options.to_map() if self.catalog_options else {},
- cache_key.token
- )
- if self.catalog_options:
- dlf_oss_endpoint =
self.catalog_options.get(CatalogOptions.DLF_OSS_ENDPOINT)
- if dlf_oss_endpoint and dlf_oss_endpoint.strip():
- merged_properties[OssOptions.OSS_ENDPOINT.key()] =
dlf_oss_endpoint
- merged_options = Options(merged_properties)
-
- file_io = FileIO.get(self.path, merged_options)
- cache[cache_key] = file_io
- return file_io, cache_key
+ cache_key = (token, self._get_file_io_scope())
+ with self._FILE_IO_CACHE_LOCK:
+ cache = self._FILE_IO_CACHE
+ file_io = cache.get(cache_key)
+ if file_io is None:
+ file_io = FileIO.get(self.path,
self._merged_options(token))
+ # Re-inserting on every access expires idle entries only, like
expireAfterAccess.
+ cache[cache_key] = file_io
+ return file_io, token
+
+ def _get_file_io_scope(self) -> str:
+ # FileIO.get binds its result to the path (e.g. the OSS bucket) and
the catalog options.
+ if self._file_io_scope is None:
+ options = self.catalog_options.to_map() if self.catalog_options
else {}
+ self._file_io_scope = repr((self.path, sorted(
+ (str(key), repr(value)) for key, value in options.items())))
+ return self._file_io_scope
+
+ def _merged_options(self, token: RESTToken) -> Options:
+ merged_properties = RESTUtil.merge(
+ self.catalog_options.to_map() if self.catalog_options else {},
+ token.token
+ )
+ if self.catalog_options:
+ dlf_oss_endpoint =
self.catalog_options.get(CatalogOptions.DLF_OSS_ENDPOINT)
+ if dlf_oss_endpoint and dlf_oss_endpoint.strip():
+ merged_properties[OssOptions.OSS_ENDPOINT.key()] =
dlf_oss_endpoint
+ return Options(merged_properties)
def _merge_token_with_catalog_options(self, token: dict) -> dict:
"""Merge token with catalog options, DLF OSS endpoint should override
the standard OSS endpoint."""
diff --git a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
index c44425e4dc..2219497a80 100644
--- a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
+++ b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
@@ -134,14 +134,8 @@ class JindoInputFile:
def __init__(self, jindo_stream, file_size=None, *, owner):
self._stream = jindo_stream
self._file_size = file_size
- # Keep-alive back-reference to the owning jindo filesystem: the
- # stream is a non-owning reference into memory owned by it. Without
- # it, dropping the FileIO that holds the handler (e.g.
- # RESTTokenFileIO's TTLCache maxsize=1 eviction on token refresh)
- # frees the native connection while other threads still read from
- # this stream. Keyword-only and required so a new open_* path cannot
- # silently skip it; it pins only the filesystem, not the handler, so
- # the evicted FileIO's properties do not stay retained.
+ # The stream does not own its native store: pin the jindo filesystem
(not the
+ # handler) so evicting the handler's FileIO from a cache cannot free
it.
self._owner = owner
self._closed = False
diff --git a/paimon-python/pypaimon/tests/jindo_file_system_test.py
b/paimon-python/pypaimon/tests/jindo_file_system_test.py
index e1ff6dcd10..3b872003a9 100644
--- a/paimon-python/pypaimon/tests/jindo_file_system_test.py
+++ b/paimon-python/pypaimon/tests/jindo_file_system_test.py
@@ -20,6 +20,7 @@ import io
import os
import pickle
import threading
+import time
import types
import unittest
import uuid
@@ -29,10 +30,14 @@ from unittest import mock
import pyarrow.fs as pafs
from pyarrow.fs import PyFileSystem
+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
from pypaimon.common.options.config import OssOptions
from pypaimon.filesystem import jindo_file_system_handler as jindo_module
from pypaimon.filesystem import oss_user_agent
+from pypaimon.filesystem import pyarrow_file_io as pyarrow_file_io_module
from pypaimon.filesystem.jindo_file_system_handler import (
JindoFileSystemHandler,
JindoInputFile,
@@ -387,9 +392,8 @@ class _EvictionRecordingNativeStream:
class JindoKeepAliveTest(unittest.TestCase):
- """Streams must keep the native store alive across FileIO eviction
- (RESTTokenFileIO token refresh) and must close before the store is
- released, without relying on attribute dealloc order."""
+ """Streams keep the native store alive across a FileIO cache eviction and
close
+ before the store is released, without relying on attribute dealloc
order."""
@staticmethod
def _handler_with_recording_fs(events):
@@ -404,7 +408,7 @@ class JindoKeepAliveTest(unittest.TestCase):
with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
mock.patch.object(jindo_module, "jfs", fake_jfs), \
mock.patch.object(jindo_module, "jutil",
-
types.SimpleNamespace(Config=_RecordingConfig)):
+
types.SimpleNamespace(Config=_RecordingConfig)):
handler = JindoFileSystemHandler("oss://bucket/", options)
return handler, weakref.ref(fake_fs), weakref.ref(handler)
@@ -413,8 +417,8 @@ class JindoKeepAliveTest(unittest.TestCase):
handler, fs_ref, handler_ref = self._handler_with_recording_fs(events)
input_file = handler.open_input_file('object')
- # TTLCache eviction on token refresh drops the handler chain: the
- # handler (and its credentials) must die, the native store must not.
+ # A FileIO cache eviction drops the handler chain: the handler (and its
+ # credentials) must die, the native store must not.
del handler
gc.collect()
self.assertIsNone(
@@ -488,6 +492,61 @@ class JindoKeepAliveTest(unittest.TestCase):
JindoOutputFile(_RecordingInputStream())
+class JindoTokenRefreshTest(unittest.TestCase):
+ """RESTTokenFileIO over the Jindo backend across a data token refresh."""
+
+ def setUp(self):
+ RESTTokenFileIO._FILE_IO_CACHE.clear()
+ self.addCleanup(RESTTokenFileIO._FILE_IO_CACHE.clear)
+
+ @staticmethod
+ def _token(security_token, expire_at_millis):
+ return RESTToken({
+ OssOptions.OSS_ENDPOINT.key(): "oss-cn-hangzhou.aliyuncs.com",
+ OssOptions.OSS_ACCESS_KEY_ID.key(): "STS.test-ak",
+ OssOptions.OSS_ACCESS_KEY_SECRET.key(): "test-sk",
+ OssOptions.OSS_SECURITY_TOKEN.key(): security_token,
+ }, expire_at_millis)
+
+ def test_token_refresh_keeps_previous_jindo_handler(self):
+ events = []
+ connected = []
+
+ def connect(*args, **kwargs):
+ fs = _EvictionRecordingFileSystem(events)
+ connected.append(weakref.ref(fs))
+ return fs
+
+ table_path = "oss://bucket/db.db/t"
+ expire_at_millis = int(time.time() * 1000) + 7_200_000
+ with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ mock.patch.object(pyarrow_file_io_module, "JINDO_AVAILABLE",
True), \
+ mock.patch.object(jindo_module, "jfs",
types.SimpleNamespace(connect=connect)), \
+ mock.patch.object(jindo_module, "jutil",
+
types.SimpleNamespace(Config=_RecordingConfig)), \
+ mock.patch.object(RESTTokenFileIO, "try_to_refresh_token"):
+ readers = [RESTTokenFileIO(Identifier.create("db", "t"),
table_path,
+ Options({OssOptions.OSS_IMPL.key():
"jindo"}))
+ for _ in range(2)]
+ for reader in readers:
+ reader.token = self._token("sts-1", expire_at_millis)
+ stream = readers[0].new_input_stream(table_path +
"/bucket-0/data.blob")
+ handler_ref = weakref.ref(readers[0].filesystem.handler)
+ self.assertIs(readers[0].file_io(), readers[1].file_io())
+ self.assertEqual(1, len(connected))
+
+ for reader in readers:
+ reader.token = self._token("sts-2", expire_at_millis + 1)
+ self.assertIs(readers[0].file_io(), readers[1].file_io())
+ gc.collect()
+
+ self.assertEqual(2, len(connected))
+ self.assertIsNotNone(handler_ref(), "token refresh released the
previous handler")
+ self.assertEqual(b"x", stream.read(1))
+ stream.close()
+ self.assertEqual(["stream-closed"], events)
+
+
class JindoFileSystemTest(unittest.TestCase):
"""Test cases for JindoFileSystem."""
diff --git a/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
b/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
index 8255a78ea9..832402f761 100644
--- a/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
+++ b/paimon-python/pypaimon/tests/rest/rest_token_file_io_test.py
@@ -15,14 +15,19 @@
# specific language governing permissions and limitations
# under the License.
+import gc
import os
import pickle
import tempfile
+import threading
import time
import unittest
+import weakref
from datetime import timedelta
from unittest.mock import patch, MagicMock
+from cachetools import TTLCache
+
from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
from pypaimon.catalog.rest.rest_token import RESTToken
@@ -32,6 +37,33 @@ from pypaimon.common.options.config import CatalogOptions,
OssOptions
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.table.row.blob import BlobDescriptor
+_FILE_IO_GET = 'pypaimon.catalog.rest.rest_token_file_io.FileIO.get'
+
+
+class _NonOwningStream:
+ """Like a pyjindo stream: does not keep the FileIO that opened it alive."""
+
+ def __init__(self, file_io):
+ self._file_io = weakref.ref(file_io)
+
+ def read(self):
+ if self._file_io() is None:
+ raise RuntimeError('use-after-free: the FileIO of this stream was
released')
+ return b'x'
+
+
+class _FakeFileIO:
+
+ def __init__(self, path, options):
+ self.path = path
+ self.options = options
+
+ def new_input_stream(self, path):
+ return _NonOwningStream(self)
+
+ def exists(self, path):
+ return True
+
class RESTTokenFileIOTest(unittest.TestCase):
"""Test cases for RESTTokenFileIO."""
@@ -42,13 +74,153 @@ class RESTTokenFileIOTest(unittest.TestCase):
self.warehouse_path = f"file://{self.temp_dir}"
self.identifier = Identifier.from_string("default.test_table")
self.catalog_options = Options({})
+ RESTTokenFileIO._FILE_IO_CACHE.clear()
def tearDown(self):
"""Clean up test fixtures."""
import shutil
+ RESTTokenFileIO._FILE_IO_CACHE.clear()
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir)
+ @staticmethod
+ def _valid_token(name):
+ return RESTToken({OssOptions.OSS_SECURITY_TOKEN.key(): name},
+ int(time.time() * 1000) + 7_200_000)
+
+ def _file_io_with_token(self, path, options, token):
+ file_io = RESTTokenFileIO(self.identifier, path, options)
+ file_io.token = token
+ return file_io
+
+ def test_token_refresh_keeps_file_io_of_open_streams(self):
+ table_path = "oss://bucket/db.db/table"
+ file_io = RESTTokenFileIO(self.identifier, table_path,
self.catalog_options)
+ self.addCleanup(RESTTokenFileIO._TOKEN_CACHE.pop, 'refresh-test', None)
+ now = [1700000000]
+ issued = []
+
+ def refresh():
+ issued.append(RESTToken(
+ {OssOptions.OSS_SECURITY_TOKEN.key():
"sts-{}".format(len(issued))},
+ (now[0] + 2 * 3600) * 1000))
+ file_io.token = issued[-1]
+
+ with patch('pypaimon.catalog.rest.rest_token_file_io.time.time',
+ side_effect=lambda: now[0]), \
+ patch.object(file_io, '_build_cache_key',
return_value='refresh-test'), \
+ patch.object(file_io, 'refresh_token', side_effect=refresh), \
+ patch(_FILE_IO_GET, side_effect=_FakeFileIO):
+ stream = file_io.new_input_stream(table_path +
"/bucket-0/data.blob")
+ # Less than an hour left: the next call refreshes the token.
+ now[0] += 3600 + 1
+ self.assertTrue(file_io.exists(table_path + "/snapshot/LATEST"))
+ gc.collect()
+
+ self.assertEqual(2, len(issued))
+ self.assertEqual(b'x', stream.read())
+
+ def test_file_io_cache_separates_paths_and_options_with_same_token(self):
+ token = self._valid_token("shared")
+ legacy = Options({OssOptions.OSS_IMPL.key(): "legacy"})
+ with patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+ patch(_FILE_IO_GET, side_effect=_FakeFileIO):
+ bucket_a = self._file_io_with_token(
+ "oss://bucket-a/db.db/t", Options({}), token).file_io()
+ bucket_b = self._file_io_with_token(
+ "oss://bucket-b/db.db/t", Options({}), token).file_io()
+ bucket_a_legacy = self._file_io_with_token(
+ "oss://bucket-a/db.db/t", legacy, token).file_io()
+
+ self.assertEqual("oss://bucket-a/db.db/t", bucket_a.path)
+ self.assertEqual("oss://bucket-b/db.db/t", bucket_b.path)
+ self.assertIsNot(bucket_a, bucket_a_legacy)
+ self.assertEqual("legacy",
bucket_a_legacy.options.get(OssOptions.OSS_IMPL))
+
+ def test_same_token_on_other_bucket_keeps_its_own_oss_bucket(self):
+ token = RESTToken({
+ OssOptions.OSS_ENDPOINT.key(): "oss-cn-hangzhou.aliyuncs.com",
+ OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
+ OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
+ }, int(time.time() * 1000) + 7_200_000)
+ options = Options({OssOptions.OSS_IMPL.key(): "legacy"})
+ with patch.object(RESTTokenFileIO, 'try_to_refresh_token'):
+ first = self._file_io_with_token("oss://bucket-a/db.db/t",
options, token)
+ second = self._file_io_with_token("oss://bucket-b/db.db/t",
options, token)
+ self.assertEqual("bucket-a", first.file_io()._oss_bucket)
+ self.assertEqual("bucket-b", second.file_io()._oss_bucket)
+
+ def test_instances_of_one_table_share_file_io(self):
+ token = self._valid_token("shared")
+ table_path = "oss://bucket/db.db/t"
+ with patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+ patch(_FILE_IO_GET, side_effect=_FakeFileIO) as get_io:
+ first = self._file_io_with_token(table_path, Options({"k": "v"}),
token)
+ second = self._file_io_with_token(table_path, Options({"k": "v"}),
token)
+ restored = pickle.loads(pickle.dumps(first))
+
+ self.assertIs(first.file_io(), second.file_io())
+ self.assertIs(first.file_io(), restored.file_io())
+ self.assertEqual(1, get_io.call_count)
+
+ def test_file_io_cache_expires_idle_entries_only(self):
+ clock = [0]
+ hour = 3600
+ cache = TTLCache(maxsize=RESTTokenFileIO._FILE_IO_CACHE_MAXSIZE,
+ ttl=RESTTokenFileIO._FILE_IO_CACHE_TTL, timer=lambda:
clock[0])
+ with patch.object(RESTTokenFileIO, '_FILE_IO_CACHE', cache), \
+ patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+ patch(_FILE_IO_GET, side_effect=_FakeFileIO):
+ file_io = self._file_io_with_token(
+ "oss://bucket/db.db/t", self.catalog_options,
self._valid_token("first"))
+ first = file_io.file_io()
+ for _ in range(3):
+ clock[0] += 9 * hour
+ self.assertIs(first, file_io.file_io())
+
+ # The previous token's FileIO stays cached until it has been idle
for the TTL.
+ file_io.token = self._valid_token("second")
+ second = file_io.file_io()
+ self.assertIsNot(first, second)
+ first_ref = weakref.ref(first)
+ del first
+ clock[0] += 9 * hour
+ file_io.file_io()
+ gc.collect()
+ self.assertIsNotNone(first_ref())
+ clock[0] += 2 * hour
+ self.assertIs(second, file_io.file_io())
+ gc.collect()
+ self.assertIsNone(first_ref())
+
+ def test_concurrent_lookups_create_one_file_io(self):
+ token = self._valid_token("shared")
+ threads_count = 8
+ barrier = threading.Barrier(threads_count)
+ results = []
+
+ def slow_file_io(path, options):
+ time.sleep(0.05)
+ return _FakeFileIO(path, options)
+
+ def lookup(file_io):
+ barrier.wait()
+ results.append(file_io.file_io())
+
+ with patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+ patch(_FILE_IO_GET, side_effect=slow_file_io) as get_io:
+ instances = [self._file_io_with_token("oss://bucket/db.db/t",
Options({}), token)
+ for _ in range(threads_count)]
+ threads = [threading.Thread(target=lookup, args=(i,)) for i in
instances]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+
+ self.assertEqual(threads_count, len(results))
+ self.assertTrue(all(r is results[0] for r in results))
+ self.assertEqual(1, get_io.call_count)
+
def test_existing_valid_token_does_not_refresh_or_read_shared_cache(self):
file_io = RESTTokenFileIO(self.identifier, self.warehouse_path)
now = 1700000000
@@ -296,7 +468,7 @@ class RESTTokenFileIOTest(unittest.TestCase):
self.assertEqual(deserialized_file_io.properties.data,
original_file_io.properties.data)
self.assertIsNone(deserialized_file_io.api_instance)
- self.assertIsNot(deserialized_file_io.file_io(), original_backend)
+ self.assertIs(deserialized_file_io.file_io(), original_backend)
test_file_path = f"file://{self.temp_dir}/pickle_test.txt"
test_content = b"pickle test content"