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 881a1bb49e [python] Keep RESTTokenFileIO usable in a forked child 
(#10556)
881a1bb49e is described below

commit 881a1bb49ec136317c43c65c8d0dddcfffedd9e1
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Wed Oct 14 21:49:44 2026 +0800

    [python] Keep RESTTokenFileIO usable in a forked child (#10556)
---
 .../pypaimon/catalog/rest/rest_token_file_io.py    | 36 ++++++++++
 .../pypaimon/tests/rest/rest_token_file_io_test.py | 80 ++++++++++++++++++++++
 2 files changed, 116 insertions(+)

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 1f83adeb31..3d808e3f33 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
@@ -16,8 +16,10 @@
 # under the License.
 
 import logging
+import os
 import threading
 import time
+import weakref
 from datetime import timedelta
 from typing import Optional, Union
 
@@ -52,6 +54,9 @@ class RESTTokenFileIO(FileIO):
     _TOKEN_LOCKS: dict = {}
     _TOKEN_LOCKS_LOCK = threading.Lock()
 
+    # Objects whose lock a forked child resets.
+    _INSTANCES = weakref.WeakSet()
+
     def __init__(self, identifier: Identifier, path: str,
                  catalog_options: Optional[Union[dict, Options]] = None):
         self.identifier = identifier
@@ -70,6 +75,7 @@ class RESTTokenFileIO(FileIO):
         self._uri_reader_factory_cache: Optional[UriReaderFactory] = None
         self._file_io_scope: Optional[str] = None
         self._token_lock = threading.Lock()
+        self._register_for_fork()
 
     def __getstate__(self):
         state = self.__dict__.copy()
@@ -88,6 +94,29 @@ class RESTTokenFileIO(FileIO):
         self.api_instance = None
         self._file_io_scope = None
         self._token_lock = threading.Lock()
+        self._register_for_fork()
+
+    def _register_for_fork(self):
+        # The cache lock is held across fork, so a child never sees the set 
mid-update.
+        with self._FILE_IO_CACHE_LOCK:
+            RESTTokenFileIO._INSTANCES.add(self)
+
+    @classmethod
+    def _before_fork(cls):
+        cls._FILE_IO_CACHE_LOCK.acquire()
+
+    @classmethod
+    def _after_fork_in_parent(cls):
+        cls._FILE_IO_CACHE_LOCK.release()
+
+    @classmethod
+    def _after_fork_in_child(cls):
+        # Parent threads that held these locks do not exist in the child.
+        cls._FILE_IO_CACHE_LOCK = threading.Lock()
+        cls._TOKEN_LOCKS_LOCK = threading.Lock()
+        cls._TOKEN_LOCKS = {}
+        for file_io in list(cls._INSTANCES):
+            file_io._token_lock = threading.Lock()
 
     def file_io(self) -> FileIO:
         return self._file_io_with_token()[0]
@@ -346,3 +375,10 @@ class RESTTokenFileIO(FileIO):
 
     def close(self):
         pass
+
+
+if hasattr(os, "register_at_fork"):
+    # Like logging: a fork waits for the cache lock, and the child starts with 
free locks.
+    os.register_at_fork(before=RESTTokenFileIO._before_fork,
+                        after_in_parent=RESTTokenFileIO._after_fork_in_parent,
+                        after_in_child=RESTTokenFileIO._after_fork_in_child)
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 832402f761..91cca6dc8a 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
@@ -18,10 +18,12 @@
 import gc
 import os
 import pickle
+import signal
 import tempfile
 import threading
 import time
 import unittest
+import warnings
 import weakref
 from datetime import timedelta
 from unittest.mock import patch, MagicMock
@@ -221,6 +223,84 @@ class RESTTokenFileIOTest(unittest.TestCase):
         self.assertTrue(all(r is results[0] for r in results))
         self.assertEqual(1, get_io.call_count)
 
+    @staticmethod
+    def _run_in_forked_child(action):
+        with warnings.catch_warnings():
+            # Python 3.12+ warns about forking with other threads alive, which 
these tests do on purpose.
+            warnings.simplefilter("ignore", DeprecationWarning)
+            pid = os.fork()
+        if pid == 0:
+            code = 1
+            try:
+                signal.alarm(10)
+                action()
+                code = 0
+            finally:
+                os._exit(code)
+        return os.waitpid(pid, 0)[1]
+
+    def _assert_child_succeeded(self, status):
+        self.assertFalse(os.WIFSIGNALED(status) and os.WTERMSIG(status) == 
signal.SIGALRM,
+                         "the forked child blocked on a lock held by a parent 
thread")
+        self.assertTrue(os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, 
status)
+
+    @unittest.skipUnless(hasattr(os, "register_at_fork"), "needs 
os.register_at_fork")
+    def test_fork_while_another_thread_creates_file_io(self):
+        token = self._valid_token("shared")
+        creating = threading.Event()
+
+        def slow_file_io(path, options):
+            if path.endswith("/slow"):
+                creating.set()
+                time.sleep(0.3)
+            return _FakeFileIO(path, options)
+
+        with patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+                patch(_FILE_IO_GET, side_effect=slow_file_io) as get_io:
+            other = self._file_io_with_token("oss://bucket/db.db/other", 
Options({}), token)
+            slow = self._file_io_with_token("oss://bucket/db.db/slow", 
Options({}), token)
+
+            def in_child():
+                other.file_io()
+                # The fork waited for the creation, so the child finds that 
FileIO cached.
+                slow.file_io()
+                assert get_io.call_count == 2, get_io.call_count
+
+            creator = threading.Thread(target=slow.file_io)
+            creator.start()
+            self.assertTrue(creating.wait(5))
+            status = self._run_in_forked_child(in_child)
+            creator.join()
+        self._assert_child_succeeded(status)
+
+    @unittest.skipUnless(hasattr(os, "register_at_fork"), "needs 
os.register_at_fork")
+    def test_fork_while_another_thread_refreshes_token(self):
+        table_path = "oss://bucket/db.db/t"
+        refreshing, release = threading.Event(), threading.Event()
+        holders = []
+
+        def refresh(file_io):
+            if threading.current_thread() in holders:
+                refreshing.set()
+                release.wait(10)
+            file_io.token = self._valid_token("refreshed")
+
+        self.addCleanup(RESTTokenFileIO._TOKEN_CACHE.pop, 'fork-test', None)
+        with patch.object(RESTTokenFileIO, '_build_cache_key', 
return_value='fork-test'), \
+                patch.object(RESTTokenFileIO, 'refresh_token', autospec=True, 
side_effect=refresh), \
+                patch(_FILE_IO_GET, side_effect=_FakeFileIO):
+            file_io = RESTTokenFileIO(self.identifier, table_path, Options({}))
+            holders.append(threading.Thread(target=file_io.file_io))
+            holders[0].start()
+            self.assertTrue(refreshing.wait(5))
+            # The child uses the object whose locks are held, and another 
object of that table.
+            status = self._run_in_forked_child(lambda: (
+                file_io.file_io(),
+                RESTTokenFileIO(self.identifier, table_path, 
Options({})).file_io()))
+            release.set()
+            holders[0].join()
+        self._assert_child_succeeded(status)
+
     def test_existing_valid_token_does_not_refresh_or_read_shared_cache(self):
         file_io = RESTTokenFileIO(self.identifier, self.warehouse_path)
         now = 1700000000

Reply via email to