blob: 02f71f1af8a01182266040c95417c6c5a2855c48 [file]
# 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()