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 179abba9be [python] Fix OSS bucket-in-endpoint mode (PyArrow < 16)
corrupting directories via CreateBucket (#8710)
179abba9be is described below
commit 179abba9beb4b7efedb970c5d8c65dc43c5021bd
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Jul 23 21:08:20 2026 +0800
[python] Fix OSS bucket-in-endpoint mode (PyArrow < 16) corrupting
directories via CreateBucket (#8710)
---
.../pypaimon/filesystem/pyarrow_file_io.py | 78 ++++++-
paimon-python/pypaimon/tests/file_io_test.py | 27 ++-
.../pypaimon/tests/oss_legacy_mode_test.py | 252 +++++++++++++++++++++
3 files changed, 343 insertions(+), 14 deletions(-)
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 8b0b4a7446..b789a5f819 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -19,6 +19,7 @@ import logging
import os
import re
import subprocess
+import threading
import uuid
from datetime import datetime, timezone
from pathlib import PurePosixPath
@@ -61,6 +62,9 @@ class PyArrowFileIO(FileIO):
self._oss_bucket = None
_oss_impl = self.properties.get(OssOptions.OSS_IMPL)
self._use_jindo = False
+ self._legacy_bucket_checked = False
+ self._legacy_bucket_error = None
+ self._legacy_bucket_lock = threading.Lock()
if self._is_oss:
self._oss_bucket = self._extract_oss_bucket(path)
@@ -87,6 +91,16 @@ class PyArrowFileIO(FileIO):
else:
raise ValueError(f"Unrecognized filesystem type in URI: {scheme}")
+ def __getstate__(self):
+ state = self.__dict__.copy()
+ # threading.Lock cannot be pickled; recreated in __setstate__.
+ state.pop("_legacy_bucket_lock", None)
+ return state
+
+ def __setstate__(self, state):
+ self.__dict__.update(state)
+ self._legacy_bucket_lock = threading.Lock()
+
@staticmethod
def parse_location(location: str):
uri = urlparse(location)
@@ -388,6 +402,11 @@ class PyArrowFileIO(FileIO):
return file_info
def list_status(self, path: str):
+ if self._legacy_oss_mode():
+ raise RuntimeError(
+ "Listing OSS directories is not supported with PyArrow < 16 "
+ "(it parses the first key segment as a bucket). Upgrade to "
+ "pyarrow >= 16, or install pyjindosdk and set
fs.oss.impl=jindo.")
path_str = self.to_filesystem_path(path)
selector = pafs.FileSelector(path_str, recursive=False,
allow_not_found=True)
return self.filesystem.get_file_info(selector)
@@ -396,7 +415,16 @@ class PyArrowFileIO(FileIO):
file_infos = self.list_status(path)
return [info for info in file_infos if info.type ==
pafs.FileType.Directory]
+ def _legacy_oss_mode(self) -> bool:
+ """OSS in bucket-in-endpoint mode (PyArrow < 16): paths are key-only
+ (the bucket is embedded in endpoint_override), but PyArrow still
+ parses the first key segment as a bucket, so bucket-level operations
+ target the wrong bucket."""
+ return self._is_oss and self._oss_bucket_in_endpoint and not
self._use_jindo
+
def exists(self, path: str) -> bool:
+ # Legacy OSS mode limitation: directories always report NotFound
+ # (see _legacy_oss_mode); plain objects are probed correctly.
path_str = self.to_filesystem_path(path)
return self._get_file_info(path_str).type != pafs.FileType.NotFound
@@ -438,17 +466,59 @@ class PyArrowFileIO(FileIO):
path_str = self.to_filesystem_path(path)
file_info = self._get_file_info(path_str)
- if file_info.type == pafs.FileType.NotFound:
- self.filesystem.create_dir(path_str, recursive=True)
- return True
if file_info.type == pafs.FileType.Directory:
return True
- elif file_info.type == pafs.FileType.File:
+ if file_info.type == pafs.FileType.File:
raise FileExistsError(f"Path exists but is not a directory:
{path}")
+ if self._legacy_oss_mode():
+ # create_dir would CreateBucket the first key segment and corrupt
+ # the parent directory; object stores need no directories. Only
+ # validate that the real bucket exists.
+ self._check_legacy_bucket_exists()
+ return True
+
self.filesystem.create_dir(path_str, recursive=True)
return True
+ def _check_legacy_bucket_exists(self):
+ """Raise if the real OSS bucket does not exist (legacy mode only).
+
+ PyArrow < 16 folds NoSuchBucket into the same NotFound as a missing
+ key, so probe the bucket root anonymously, at most once per
+ instance (the lock serializes concurrent writers). Reject only on
+ an OSS NoSuchBucket error body - a bare 404 may come from a proxy
+ or custom endpoint. Any probe setup or transport failure
+ (Requests-only TLS/proxy settings do not apply to PyArrow's S3
+ client) is indeterminate: fail open."""
+ with self._legacy_bucket_lock:
+ if not self._legacy_bucket_checked:
+ self._legacy_bucket_error = self._probe_legacy_bucket()
+ self._legacy_bucket_checked = True
+ if self._legacy_bucket_error:
+ raise OSError(self._legacy_bucket_error)
+
+ def _probe_legacy_bucket(self) -> Optional[str]:
+ """Return an error message if the bucket definitely does not exist."""
+ import requests
+
+ endpoint = self.properties.get(OssOptions.OSS_ENDPOINT) or ""
+ scheme, _, host = endpoint.rpartition("://")
+ url = f"{scheme or 'https'}://{self._oss_bucket}.{host}/"
+ try:
+ response = requests.get(url, timeout=5, allow_redirects=False,
stream=True)
+ try:
+ status = response.status_code
+ body = next(response.iter_content(2048), b"") if status == 404
else b""
+ finally:
+ response.close()
+ except (requests.RequestException, OSError):
+ return None
+ if status == 404 and b"NoSuchBucket" in body:
+ return (f"OSS bucket '{self._oss_bucket}' does not exist "
+ f"(NoSuchBucket from {url})")
+ return None
+
def rename(self, src: str, dst: str) -> bool:
dst_str = self.to_filesystem_path(dst)
dst_parent = PurePosixPath(dst_str).parent
diff --git a/paimon-python/pypaimon/tests/file_io_test.py
b/paimon-python/pypaimon/tests/file_io_test.py
index fd5f00306c..c7d2ebc6c7 100644
--- a/paimon-python/pypaimon/tests/file_io_test.py
+++ b/paimon-python/pypaimon/tests/file_io_test.py
@@ -97,14 +97,19 @@ class FileIOTest(unittest.TestCase):
mock_fs.create_dir = MagicMock()
mock_fs.open_output_stream.return_value = MagicMock()
oss_io.filesystem = mock_fs
- oss_io.new_output_stream("oss://test-bucket/path/to/file.txt")
- mock_fs.create_dir.assert_called_once()
- path_str =
oss_io.to_filesystem_path("oss://test-bucket/path/to/file.txt")
+ # Bucket-in-endpoint mode (PyArrow < 16) mkdirs probes the real bucket
+ # over HTTP; keep the test offline.
+ with patch("requests.get", return_value=MagicMock(status_code=403)):
+ oss_io.new_output_stream("oss://test-bucket/path/to/file.txt")
if bucket_stripped:
- expected_parent = '/'.join(path_str.split('/')[:-1]) if '/' in
path_str else ''
+ # Legacy mkdirs must not create_dir (it would CreateBucket the
+ # first key segment and corrupt the parent directory).
+ mock_fs.create_dir.assert_not_called()
else:
+ mock_fs.create_dir.assert_called_once()
+ path_str =
oss_io.to_filesystem_path("oss://test-bucket/path/to/file.txt")
expected_parent = "/".join(path_str.split("/")[:-1]) if "/" in
path_str else str(Path(path_str).parent)
- self.assertEqual(mock_fs.create_dir.call_args[0][0], expected_parent)
+ self.assertEqual(mock_fs.create_dir.call_args[0][0],
expected_parent)
if bucket_stripped:
for call_paths in get_file_info_calls:
for p in call_paths:
@@ -480,11 +485,13 @@ class FileIOTest(unittest.TestCase):
mock_fs.copy_file = MagicMock()
oss_io.filesystem = mock_fs
-
oss_io.new_output_stream("oss://test-bucket/db.db/tbl/bucket-0/data.parquet")
- oss_io.rename("oss://test-bucket/db.db/tbl/old.parquet",
- "oss://test-bucket/db.db/tbl/new.parquet")
- oss_io.copy_file("oss://test-bucket/db.db/tbl/src.parquet",
- "oss://test-bucket/db.db/tbl/dst.parquet")
+ # PyArrow < 16 mkdirs probes the real bucket over HTTP; keep the test
offline.
+ with patch("requests.get", return_value=MagicMock(status_code=403)):
+
oss_io.new_output_stream("oss://test-bucket/db.db/tbl/bucket-0/data.parquet")
+ oss_io.rename("oss://test-bucket/db.db/tbl/old.parquet",
+ "oss://test-bucket/db.db/tbl/new.parquet")
+ oss_io.copy_file("oss://test-bucket/db.db/tbl/src.parquet",
+ "oss://test-bucket/db.db/tbl/dst.parquet")
for call in mock_fs.create_dir.call_args_list:
self.assertNotIn("\\", call[0][0], f"backslash in path:
{call[0][0]}")
diff --git a/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
b/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
new file mode 100644
index 0000000000..02f71f1af8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
@@ -0,0 +1,252 @@
+# 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.
+
+"""Unit tests for the OSS bucket-in-endpoint mode (PyArrow < 16) of
PyArrowFileIO.
+
+See ``PyArrowFileIO._legacy_oss_mode`` for why bucket-level operations
+must be guarded in this mode. No real OSS access is required.
+"""
+
+import unittest
+from unittest import mock
+
+import pyarrow.fs as pafs
+
+from pypaimon.common.options import Options
+from pypaimon.common.options.config import OssOptions
+from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
+
+
+TABLE_PATH = "oss://test-bucket/db-uuid.db/tbl-uuid"
+
+
+def _file_info(path, file_type):
+ return pafs.FileInfo(path, file_type)
+
+
+def _probe_response(status_code, body):
+ response = mock.MagicMock(status_code=status_code)
+ response.iter_content.return_value = iter([body])
+ return response
+
+
+class OssLegacyModeTest(unittest.TestCase):
+ """Behavior of PyArrowFileIO when OSS runs on PyArrow < 16."""
+
+ def _new_file_io(self, legacy):
+ options = Options({
+ OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
+ OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
+ OssOptions.OSS_ENDPOINT.key(): "oss-cn-test.example.com",
+ OssOptions.OSS_REGION.key(): "cn-test",
+ OssOptions.OSS_IMPL.key(): "legacy",
+ })
+ with mock.patch.object(
+ PyArrowFileIO, "_initialize_oss_fs", return_value=mock.Mock()):
+ file_io = PyArrowFileIO("oss://test-bucket/", options)
+ # _legacy_oss_mode() keys off the bucket-in-endpoint flag (PyArrow <
16).
+ file_io._oss_bucket_in_endpoint = legacy
+ file_io.filesystem = mock.Mock()
+ return file_io
+
+ def test_legacy_mkdirs_skips_create_dir(self):
+ """create_dir would CreateBucket and corrupt the parent directory."""
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ with mock.patch("requests.get") as get:
+ get.return_value = mock.MagicMock(status_code=403)
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ get.assert_called_once_with(
+ "https://test-bucket.oss-cn-test.example.com/",
+ timeout=5, allow_redirects=False, stream=True)
+
+ file_io.filesystem.create_dir.assert_not_called()
+
+ def test_legacy_mkdirs_raises_when_bucket_missing(self):
+ """mkdirs must not report success for a missing real bucket."""
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ with mock.patch("requests.get") as get:
+ get.return_value = _probe_response(
+ 404, b"<Error><Code>NoSuchBucket</Code></Error>")
+ with self.assertRaises(OSError) as ctx:
+ file_io.mkdirs(TABLE_PATH)
+ self.assertIn("does not exist", str(ctx.exception))
+ file_io.filesystem.create_dir.assert_not_called()
+
+ def test_legacy_mkdirs_allows_bare_404_from_custom_endpoint(self):
+ """A 404 without the OSS NoSuchBucket body must not reject the
bucket."""
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ with mock.patch("requests.get") as get:
+ get.return_value = _probe_response(404, b"not found")
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ # The indeterminate probe is cached; no repeat per mkdirs.
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ self.assertEqual(get.call_count, 1)
+
+ def test_legacy_mkdirs_fails_open_and_caches_on_transport_error(self):
+ """A probe transport failure must neither fail mkdirs nor repeat
+ the probe (and its timeout) on every subsequent write."""
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ import requests
+ with mock.patch("requests.get") as get:
+ get.side_effect = requests.ConnectionError("boom")
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ self.assertEqual(get.call_count, 1)
+
+ def test_legacy_mkdirs_fails_open_on_plain_oserror(self):
+ """Requests-only setup errors (e.g. a broken REQUESTS_CA_BUNDLE)
+ raise plain OSError; they must not abort legacy writes."""
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ with mock.patch("requests.get") as get:
+ get.side_effect = OSError(
+ "Could not find a suitable TLS CA certificate bundle")
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+ self.assertEqual(get.call_count, 1)
+
+ def test_legacy_mkdirs_probe_is_serialized_across_threads(self):
+ """Concurrent writers must share one probe and all observe its
+ published result."""
+ import threading
+ import time
+
+ for body, expect_error in [
+ (b"<Error><Code>NoSuchBucket</Code></Error>", True),
+ (b"not found", False)]:
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ def slow_get(*args, **kwargs):
+ time.sleep(0.05)
+ return _probe_response(404, body)
+
+ results = []
+
+ def call_mkdirs():
+ try:
+ results.append(file_io.mkdirs(TABLE_PATH))
+ except OSError:
+ results.append("raised")
+
+ with mock.patch("requests.get", side_effect=slow_get) as get:
+ threads = [threading.Thread(target=call_mkdirs) for _ in
range(4)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ self.assertEqual(get.call_count, 1)
+ expected = "raised" if expect_error else True
+ self.assertEqual(results, [expected] * 4)
+
+ def test_legacy_mkdirs_missing_bucket_keeps_raising_without_reprobe(self):
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.NotFound)]
+
+ with mock.patch("requests.get") as get:
+ get.return_value = _probe_response(
+ 404, b"<Error><Code>NoSuchBucket</Code></Error>")
+ for _ in range(2):
+ with self.assertRaises(OSError):
+ file_io.mkdirs(TABLE_PATH)
+ self.assertEqual(get.call_count, 1)
+
+ def test_legacy_mkdirs_still_rejects_file_conflict(self):
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("db-uuid.db/tbl-uuid", pafs.FileType.File)]
+
+ with self.assertRaises(FileExistsError):
+ file_io.mkdirs(TABLE_PATH)
+ file_io.filesystem.create_dir.assert_not_called()
+
+ def test_modern_mkdirs_still_creates_dir(self):
+ file_io = self._new_file_io(legacy=False)
+ file_io.filesystem.get_file_info.return_value = [
+ _file_info("test-bucket/db-uuid.db/tbl-uuid",
pafs.FileType.NotFound)]
+
+ self.assertTrue(file_io.mkdirs(TABLE_PATH))
+
+ file_io.filesystem.create_dir.assert_called_once()
+
+ def test_file_io_pickle_roundtrip_recreates_lock(self):
+ """The probe lock must not break pickling (FileIO travels to Ray or
+ multiprocessing workers); probe state is carried over."""
+ import pickle
+
+ options = Options({
+ OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
+ OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
+ OssOptions.OSS_ENDPOINT.key(): "oss-cn-test.example.com",
+ OssOptions.OSS_REGION.key(): "cn-test",
+ OssOptions.OSS_IMPL.key(): "legacy",
+ })
+ file_io = PyArrowFileIO("oss://test-bucket/wh", options)
+ file_io._legacy_bucket_checked = True
+ file_io._legacy_bucket_error = "OSS bucket 'test-bucket' does not
exist"
+
+ restored = pickle.loads(pickle.dumps(file_io))
+
+ self.assertIsNotNone(restored._legacy_bucket_lock)
+ # Cached probe verdict survives; no re-probe in the worker.
+ with mock.patch("requests.get") as get:
+ with self.assertRaises(OSError):
+ restored._check_legacy_bucket_exists()
+ get.assert_not_called()
+
+ def test_legacy_exists_true_for_plain_object(self):
+ file_io = self._new_file_io(legacy=True)
+ file_io.filesystem.get_file_info.side_effect = lambda paths: [
+ _file_info(paths[0], pafs.FileType.File)]
+
+ self.assertTrue(file_io.exists(TABLE_PATH + "/data-1.parquet"))
+
+ def test_legacy_list_status_raises_actionable_error(self):
+ """Fail fast instead of the misleading raw NoSuchKey selector error."""
+ file_io = self._new_file_io(legacy=True)
+
+ with self.assertRaises(RuntimeError) as ctx:
+ file_io.list_status(TABLE_PATH)
+ self.assertIn("pyarrow >= 16", str(ctx.exception))
+ file_io.filesystem.get_file_info.assert_not_called()
+
+ def test_modern_list_status_uses_selector(self):
+ file_io = self._new_file_io(legacy=False)
+ file_io.filesystem.get_file_info.return_value = []
+
+ self.assertEqual(file_io.list_status(TABLE_PATH), [])
+ file_io.filesystem.get_file_info.assert_called_once()
+
+
+if __name__ == "__main__":
+ unittest.main()