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 33a10541b3 [python] Log FileIO cache and Jindo stream health at a 
bounded rate (#10580)
33a10541b3 is described below

commit 33a10541b38a6d88321630bf6cb809b03d1fdeda
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sun Oct 18 16:50:10 2026 +0800

    [python] Log FileIO cache and Jindo stream health at a bounded rate (#10580)
---
 .../pypaimon/catalog/rest/rest_token_file_io.py    |  65 +++++++++++++
 .../filesystem/jindo_file_system_handler.py        |  40 ++++++++
 .../pypaimon/tests/jindo_file_system_test.py       |  64 +++++++++++++
 .../pypaimon/tests/rest/rest_token_file_io_test.py | 105 +++++++++++++++++++++
 4 files changed, 274 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 fbb39480ed..94e044652e 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_token_file_io.py
@@ -59,6 +59,12 @@ class RESTTokenFileIO(FileIO):
     # Objects whose lock a forked child resets.
     _INSTANCES = weakref.WeakSet()
 
+    # Cache misses log a summary at most this often; idle processes log 
nothing.
+    _STATS_LOG_INTERVAL = 600
+    _FILE_IO_STATS = {"created": 0, "evicted": 0, "warned": False, "next_log": 
0.0,
+                      "fds": None, "next_scan": 0.0}
+    _SHORT_TOKEN_STATS = {"count": 0, "next_log": 0.0}
+
     def __init__(self, identifier: Identifier, path: str,
                  catalog_options: Optional[Union[dict, Options]] = None):
         self.identifier = identifier
@@ -146,10 +152,44 @@ class RESTTokenFileIO(FileIO):
                 file_io = cache.get(cache_key)
                 if file_io is None:
                     file_io = FileIO.get(self.path, 
self._merged_options(token))
+                    self._log_file_io_created(cache)
                 # Re-inserting on every access expires idle entries only, like 
expireAfterAccess.
                 cache[cache_key] = file_io
             return file_io, token
 
+    def _log_file_io_created(self, cache):
+        # Runs under the cache lock right before the new FileIO is inserted.
+        stats = RESTTokenFileIO._FILE_IO_STATS
+        stats["created"] += 1
+        cache.expire()
+        if len(cache) >= cache.maxsize:
+            stats["evicted"] += 1
+        now = time.monotonic()
+        quiet = now < stats["next_log"]
+        if quiet and stats["warned"]:
+            return
+        fds = self._sample_open_files(stats, now)
+        crowded = stats["evicted"] > 0 or (fds is not None and fds[0] * 5 > 
fds[1] * 4)
+        # The first sign of trouble is logged at once; anything else waits for 
the interval.
+        if quiet and not crowded:
+            return
+        self.log.log(
+            logging.WARNING if crowded else logging.INFO,
+            "RESTTokenFileIO cache holds %d/%d FileIOs and %d tokens; %d 
FileIOs created and "
+            "%d evicted for size since the last summary; open files %s",
+            min(len(cache) + 1, cache.maxsize), cache.maxsize, 
len(self._TOKEN_CACHE),
+            stats["created"], stats["evicted"], "unknown" if fds is None else 
"%d/%d" % fds)
+        stats.update(created=0, evicted=0, warned=crowded,
+                     next_log=now + self._STATS_LOG_INTERVAL)
+
+    @staticmethod
+    def _sample_open_files(stats, now):
+        # Listing /proc/self/fd takes about 0.5 ms per 1,000 descriptors, so 
scan at most 1% of the time.
+        if now >= stats["next_scan"]:
+            stats["fds"] = _open_files()
+            stats["next_scan"] = now + 100 * (time.monotonic() - now)
+        return stats["fds"]
+
     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:
@@ -385,6 +425,23 @@ class RESTTokenFileIO(FileIO):
         merged_token_dict = 
self._merge_token_with_catalog_options(response.token)
         new_token = RESTToken(merged_token_dict, response.expires_at_millis)
         self.token = new_token
+        if self._is_token_expired(new_token):
+            self._log_short_token(new_token)
+
+    def _log_short_token(self, token: RESTToken):
+        with self._TOKEN_LOCKS_LOCK:
+            stats = RESTTokenFileIO._SHORT_TOKEN_STATS
+            stats["count"] += 1
+            now = time.monotonic()
+            if now < stats["next_log"]:
+                return
+            count = stats["count"]
+            stats.update(count=0, next_log=now + self._STATS_LOG_INTERVAL)
+        self.log.warning(
+            "Data token for [%s] expires in %d s, within the %d s refresh 
margin, so every "
+            "access refreshes it; %d such tokens in the last %d s",
+            self.identifier, (token.expire_at_millis - int(time.time() * 
1000)) // 1000,
+            RESTApi.TOKEN_EXPIRATION_SAFE_TIME_MILLIS // 1000, count, 
self._STATS_LOG_INTERVAL)
 
     def valid_token(self):
         self.try_to_refresh_token()
@@ -399,6 +456,14 @@ class RESTTokenFileIO(FileIO):
         pass
 
 
+def _open_files():
+    try:
+        import resource
+        return len(os.listdir("/proc/self/fd")), 
resource.getrlimit(resource.RLIMIT_NOFILE)[0]
+    except (ImportError, OSError):
+        return None
+
+
 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,
diff --git a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py 
b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
index 2219497a80..93d255f4a0 100644
--- a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
+++ b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
@@ -57,6 +57,42 @@ _CASE_SENSITIVE_JINDO_CONFIG_KEYS = {
 }
 _KNOWN_FILE_SIZE_CACHE_MAX_ENTRIES = 4096
 
+_LOG = logging.getLogger(__name__)
+
+
+class _StreamStats:
+    """Counts open streams of JindoFileSystemHandler, not those PVFS opens 
through pyjindo.ossfs."""
+
+    # Reentrant: garbage collection may finalize a stream while this thread 
holds it.
+    _lock = threading.RLock()
+    _pid = os.getpid()
+    open = 0
+    milestone = 256
+
+    @classmethod
+    def _get_lock(cls):
+        # A parent thread may hold the lock across a fork, so a child takes a 
new one.
+        if cls._pid != os.getpid():
+            cls._lock, cls._pid = threading.RLock(), os.getpid()
+        return cls._lock
+
+    @classmethod
+    def opened(cls):
+        with cls._get_lock():
+            cls.open += 1
+            count = cls.open
+            report = count >= cls.milestone
+            if report:
+                cls.milestone *= 2
+        if report:
+            _LOG.info("%d streams of JindoFileSystemHandler are open; each 
holds native buffers until closed",
+                      count)
+
+    @classmethod
+    def closed(cls):
+        with cls._get_lock():
+            cls.open -= 1
+
 
 def _jindo_config_value(value) -> str:
     if isinstance(value, bool):
@@ -138,6 +174,7 @@ class JindoInputFile:
         # handler) so evicting the handler's FileIO from a cache cannot free 
it.
         self._owner = owner
         self._closed = False
+        _StreamStats.opened()
 
     def __del__(self):
         # CPython releases instance attributes in dict layout order, which is
@@ -191,6 +228,7 @@ class JindoInputFile:
             # The native stream is closed: a file object that stays
             # referenced no longer needs to pin the native store.
             self._owner = None
+            _StreamStats.closed()
 
     def __enter__(self):
         return self
@@ -207,6 +245,7 @@ class JindoOutputFile:
         # stream is open.
         self._owner = owner
         self._closed = False
+        _StreamStats.opened()
 
     def __del__(self):
         # See JindoInputFile.__del__: close while ``_owner`` still pins the
@@ -244,6 +283,7 @@ class JindoOutputFile:
             # The native stream is closed: a file object that stays
             # referenced no longer needs to pin the native store.
             self._owner = None
+            _StreamStats.closed()
 
     def __enter__(self):
         return self
diff --git a/paimon-python/pypaimon/tests/jindo_file_system_test.py 
b/paimon-python/pypaimon/tests/jindo_file_system_test.py
index 3b872003a9..96743573d0 100644
--- a/paimon-python/pypaimon/tests/jindo_file_system_test.py
+++ b/paimon-python/pypaimon/tests/jindo_file_system_test.py
@@ -19,11 +19,13 @@ import gc
 import io
 import os
 import pickle
+import signal
 import threading
 import time
 import types
 import unittest
 import uuid
+import warnings
 import weakref
 from unittest import mock
 
@@ -95,6 +97,68 @@ class JindoInputFileTest(unittest.TestCase):
         self.assertEqual([60], stream.reads)
 
 
+class JindoStreamStatsTest(unittest.TestCase):
+
+    def setUp(self):
+        stats = jindo_module._StreamStats
+        for name in ("open", "milestone"):
+            self.addCleanup(setattr, stats, name, getattr(stats, name))
+        stats.open, stats.milestone = 0, 2
+
+    def test_logs_only_when_open_streams_pass_a_power_of_two(self):
+        with self.assertLogs(jindo_module.__name__, level="INFO") as logs:
+            files = [JindoInputFile(_RecordingInputStream(), owner=object()) 
for _ in range(3)]
+            # The output stream is the one that passes 4.
+            files.append(JindoOutputFile(_RecordingInputStream(), 
owner=object()))
+        self.assertEqual(["2 streams of JindoFileSystemHandler are open",
+                          "4 streams of JindoFileSystemHandler are open"],
+                         [r.getMessage().split(";")[0] for r in logs.records])
+
+        files.pop().close()
+        self.assertEqual(3, jindo_module._StreamStats.open)
+        files.pop(0).close()
+        # Dropping an unclosed stream closes it, so it no longer counts as 
open.
+        files.pop(0)
+        gc.collect()
+        self.assertEqual(1, jindo_module._StreamStats.open)
+        files[0].close()
+        files[0].close()
+        self.assertEqual(0, jindo_module._StreamStats.open)
+
+    @unittest.skipUnless(hasattr(os, "fork"), "needs os.fork")
+    def 
test_forked_child_counts_streams_while_a_parent_thread_holds_the_lock(self):
+        held, release = threading.Event(), threading.Event()
+
+        def hold():
+            with jindo_module._StreamStats._lock:
+                held.set()
+                release.wait()
+
+        thread = threading.Thread(target=hold)
+        thread.start()
+        held.wait()
+        try:
+            with warnings.catch_warnings():
+                # Python 3.12+ warns about forking with other threads alive, 
which this test does on purpose.
+                warnings.simplefilter("ignore", DeprecationWarning)
+                pid = os.fork()
+            if pid == 0:
+                code = 1
+                try:
+                    signal.alarm(10)
+                    JindoInputFile(_RecordingInputStream(), 
owner=object()).close()
+                    code = 0 if jindo_module._StreamStats.open == 0 else 2
+                finally:
+                    os._exit(code)
+            status = os.waitpid(pid, 0)[1]
+        finally:
+            release.set()
+            thread.join()
+        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)
+
+
 class JindoConfigTest(unittest.TestCase):
 
     def test_input_file_seek_returns_position(self):
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 a7b88220d0..ad76d12d03 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
@@ -22,6 +22,7 @@ import signal
 import tempfile
 import threading
 import time
+import types
 import unittest
 import warnings
 import weakref
@@ -244,6 +245,110 @@ class RESTTokenFileIOTest(unittest.TestCase):
                          "the forked child blocked on a lock held by a parent 
thread")
         self.assertTrue(os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, 
status)
 
+    def _touch_with_summary_stats(self, cache, stats, clock, open_files, 
steps):
+        # Each step sets the clock and open files, then reads through a FileIO 
with that token name.
+        module = 'pypaimon.catalog.rest.rest_token_file_io'
+        with patch.object(RESTTokenFileIO, '_FILE_IO_CACHE', cache), \
+                patch.object(RESTTokenFileIO, '_FILE_IO_STATS', stats), \
+                patch.object(RESTTokenFileIO, 'try_to_refresh_token'), \
+                patch(module + '.time', 
types.SimpleNamespace(monotonic=lambda: clock[0], time=time.time)), \
+                patch(_FILE_IO_GET, side_effect=_FakeFileIO), \
+                self.assertLogs(module, level='INFO') as logs:
+            for now, files, name in steps:
+                clock[0] = now
+                if files is not None:
+                    open_files[0] = files
+                if name is None:
+                    cache.clear()
+                    continue
+                self._file_io_with_token(
+                    self.warehouse_path, self.catalog_options, 
self._valid_token(name)).exists("x")
+        return logs.records
+
+    def test_cache_misses_log_one_summary_per_interval(self):
+        cache = TTLCache(maxsize=3, ttl=1e9)
+        stats = dict(RESTTokenFileIO._FILE_IO_STATS, created=0, evicted=0, 
warned=False, next_log=0.0,
+                     fds=None, next_scan=0.0)
+        open_files = [(10, 1000)]
+        scan = MagicMock(side_effect=lambda: open_files[0])
+        with patch('pypaimon.catalog.rest.rest_token_file_io._open_files', 
scan):
+            records = self._touch_with_summary_stats(cache, stats, [0.0], 
open_files, [
+                (1000, None, "a"),
+                (1100, None, "b"),
+                # Crowded descriptors warn at once, even within the interval 
of an INFO summary.
+                (1200, (900, 1000), "c"),
+                # After a warning, misses neither log nor list descriptors 
until the interval ends.
+                (1300, None, "d"),
+                (1801, (10, 1000), "e"),
+                (2402, None, None),
+                (2402, None, "f"),
+            ])
+
+        self.assertEqual(['INFO', 'WARNING', 'WARNING', 'INFO'], [r.levelname 
for r in records])
+        messages = [r.getMessage() for r in records]
+        self.assertIn("holds 1/3 FileIOs", messages[0])
+        self.assertIn("1 FileIOs created and 0 evicted", messages[0])
+        self.assertIn("open files 10/1000", messages[0])
+        self.assertIn("holds 3/3 FileIOs", messages[1])
+        self.assertIn("2 FileIOs created and 0 evicted", messages[1])
+        self.assertIn("open files 900/1000", messages[1])
+        self.assertIn("2 FileIOs created and 2 evicted", messages[2])
+        self.assertIn("open files 10/1000", messages[2])
+        self.assertIn("1 FileIOs created and 0 evicted", messages[3])
+        self.assertEqual(5, scan.call_count)
+
+    def test_open_files_are_listed_at_most_one_percent_of_the_time(self):
+        clock = [100.0]
+        stats = {"fds": None, "next_scan": 0.0}
+
+        def scan():
+            clock[0] += 0.002
+            return 10, 1000
+
+        module = 'pypaimon.catalog.rest.rest_token_file_io'
+        with patch(module + '._open_files', side_effect=scan) as open_files, \
+                patch(module + '.time', 
types.SimpleNamespace(monotonic=lambda: clock[0], time=time.time)):
+            for now in (100.0, 100.1, 100.19, 100.21):
+                clock[0] = now
+                self.assertEqual((10, 1000), 
RESTTokenFileIO._sample_open_files(stats, now))
+        # A 2 ms scan at 100.0 defers the next one to 100.2.
+        self.assertEqual(2, open_files.call_count)
+
+    def 
test_cache_miss_reports_unknown_open_files_when_they_cannot_be_listed(self):
+        real_listdir = os.listdir
+
+        def listdir(path="."):
+            if path == "/proc/self/fd":
+                raise OSError("not listable")
+            return real_listdir(path)
+
+        stats = dict(RESTTokenFileIO._FILE_IO_STATS, created=0, evicted=0, 
warned=False, next_log=0.0,
+                     fds=None, next_scan=0.0)
+        with patch('os.listdir', side_effect=listdir):
+            records = self._touch_with_summary_stats(
+                TTLCache(maxsize=3, ttl=1e9), stats, [0.0], [None], [(1000, 
None, "a")])
+        self.assertEqual(1, len(records))
+        self.assertEqual('INFO', records[0].levelname)
+        self.assertIn("open files unknown", records[0].getMessage())
+
+    def test_short_lived_token_warns_once_per_interval(self):
+        file_io = RESTTokenFileIO(self.identifier, self.warehouse_path, 
self.catalog_options)
+        file_io.api_instance = MagicMock()
+        now = int(time.time() * 1000)
+        lifetimes = [7_200_000, 1_800_000, 1_800_000, 1_800_000]
+        responses = [MagicMock(token={'ak': 'test-ak'}, expires_at_millis=now 
+ lifetime)
+                     for lifetime in lifetimes]
+        file_io.api_instance.load_table_token.side_effect = responses
+
+        with patch.object(RESTTokenFileIO, '_SHORT_TOKEN_STATS', {"count": 0, 
"next_log": 0.0}), \
+                self.assertLogs('pypaimon.catalog.rest.rest_token_file_io', 
level='WARNING') as logs:
+            for _ in lifetimes:
+                file_io.refresh_token()
+
+        self.assertEqual(1, len(logs.records))
+        self.assertIn("within the 3600 s refresh margin", 
logs.records[0].getMessage())
+        self.assertIn("1 such tokens", logs.records[0].getMessage())
+
     @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")

Reply via email to