blob: 3e54567797473b507beefb2312dcfb2ad0c9c126 [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 PyArrow-backed OSS and S3-compatible storage.
No real OSS access is required.
"""
import multiprocessing
import os
import pickle
import unittest
import threading
from itertools import product
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from unittest import mock
import pyarrow
import pyarrow.fs as pafs
from packaging.version import parse
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions, S3Options
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
def _restore_s3_file_io(connection):
os.environ.pop("AWS_REQUEST_CHECKSUM_CALCULATION", None)
connection.send("ready")
payload = connection.recv_bytes()
client = object()
settings = []
def create_client(**kwargs):
settings.append(os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
return client
with mock.patch("pyarrow.fs.S3FileSystem", side_effect=create_client) as s3:
restored = pickle.loads(payload)
connection.send((
os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"),
settings,
s3.call_count,
restored.filesystem is client,
))
connection.close()
def _write_in_worker(connection):
os.environ.pop("AWS_REQUEST_CHECKSUM_CALCULATION", None)
connection.send("ready")
try:
file_io = pickle.loads(connection.recv_bytes())
with file_io.filesystem.open_output_stream("test-bucket/file") as stream:
stream.write(b"x")
connection.send("written")
except Exception as error:
connection.send(repr(error))
finally:
connection.close()
class _HTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class _UploadHandler(BaseHTTPRequestHandler):
def _respond(self, body=b""):
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.send_header("ETag", '"etag"')
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
self.close_connection = True
def do_POST(self):
if "uploads" in self.path:
self._respond(b"<InitiateMultipartUploadResult><Bucket>test-bucket</Bucket>"
b"<Key>file</Key><UploadId>test-id</UploadId>"
b"</InitiateMultipartUploadResult>")
else:
self._respond(b"<CompleteMultipartUploadResult><Bucket>test-bucket</Bucket>"
b"<Key>file</Key><ETag>etag</ETag></CompleteMultipartUploadResult>")
def do_PUT(self):
self.server.put_headers.append(dict(self.headers))
self._respond()
def log_message(self, *args):
pass
class S3ClientCompatibilityTest(unittest.TestCase):
def setUp(self):
environment = mock.patch.dict(os.environ)
environment.start()
self.addCleanup(environment.stop)
os.environ.pop("AWS_REQUEST_CHECKSUM_CALCULATION", None)
def _expected_checksum_default(self):
return "WHEN_REQUIRED" if parse(pyarrow.__version__) >= parse("22.0.0") else None
def test_checksum_default_version_boundary(self):
for version in ("19.0.1", "20.0.0", "21.0.0", "22.0.0", "23.0.0"):
for scheme in ("oss", "s3", "s3a", "s3n"):
with self.subTest(version=version, scheme=scheme), \
mock.patch.dict(os.environ), \
mock.patch.object(pyarrow, "__version__", version):
os.environ.pop("AWS_REQUEST_CHECKSUM_CALCULATION", None)
self._new_file_io(scheme)
expected = "WHEN_REQUIRED" if version in ("22.0.0", "23.0.0") else None
self.assertEqual(expected, os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
def test_checksum_compatibility_can_be_disabled(self):
with mock.patch("pyarrow.fs.S3FileSystem", return_value=mock.Mock()):
PyArrowFileIO("s3://test-bucket/", Options({
"fs.s3.endpoint": "http://minio:9000",
"fs.s3.checksum-compatibility.auto-configure": "false",
}))
self.assertNotIn("AWS_REQUEST_CHECKSUM_CALCULATION", os.environ)
@mock.patch.object(pyarrow, "__version__", "23.0.0")
def test_disabling_auto_configuration_preserves_existing_process_default(self):
settings = []
def create_client(**kwargs):
settings.append(os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
return mock.Mock()
with mock.patch("pyarrow.fs.S3FileSystem", side_effect=create_client):
for auto_configure in ("true", "false"):
PyArrowFileIO("s3://test-bucket/", Options({
"fs.s3.endpoint": "http://minio:9000",
"fs.s3.checksum-compatibility.auto-configure": auto_configure,
}))
self.assertEqual(["WHEN_REQUIRED", "WHEN_REQUIRED"], settings)
self.assertEqual("WHEN_REQUIRED", os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"])
def _new_file_io(self, scheme="s3"):
if scheme == "oss":
options = Options({
OssOptions.OSS_IMPL.key(): "legacy",
OssOptions.OSS_ENDPOINT.key(): "oss-cn-test.example.com",
OssOptions.OSS_ACCESS_KEY_ID.key(): "ak",
OssOptions.OSS_ACCESS_KEY_SECRET.key(): "sk",
})
else:
options = Options({S3Options.S3_ENDPOINT.key(): "http://minio:9000"})
with mock.patch("pyarrow.fs.S3FileSystem", return_value=pafs.LocalFileSystem()):
file_io = PyArrowFileIO(scheme + "://test-bucket/", options)
return file_io
def test_process_default_retained_after_client_creation_failure(self):
for previous in (None, "WHEN_SUPPORTED"):
with self.subTest(previous=previous), mock.patch.dict(os.environ, {}, clear=True):
if previous is not None:
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"] = previous
with mock.patch("pyarrow.fs.S3FileSystem", side_effect=RuntimeError("failed")):
with self.assertRaisesRegex(RuntimeError, "failed"):
self._new_file_io_failure()
self.assertEqual(previous or self._expected_checksum_default(),
os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
def _new_file_io_failure(self):
PyArrowFileIO("s3://test-bucket/", Options({"fs.s3.endpoint": "http://minio:9000"}))
def test_pickle_rebuilds_client_in_started_worker(self):
methods = [name for name in ("spawn", "fork")
if name in multiprocessing.get_all_start_methods()]
for scheme, method in product(("oss", "s3", "s3a", "s3n"), methods):
with self.subTest(scheme=scheme, method=method):
context = multiprocessing.get_context(method)
parent, child = context.Pipe()
process = context.Process(target=_restore_s3_file_io, args=(child,))
process.start()
child.close()
try:
self.assertTrue(parent.poll(20))
self.assertEqual("ready", parent.recv())
file_io = self._new_file_io(scheme)
self.assertNotIn("filesystem", file_io.__getstate__())
parent.send_bytes(pickle.dumps(file_io))
self.assertTrue(parent.poll(20))
expected = self._expected_checksum_default()
self.assertEqual((expected, [expected], 1, True), parent.recv())
finally:
parent.close()
process.join(20)
if process.is_alive():
process.terminate()
process.join()
self.assertEqual(0, process.exitcode)
def test_oss_initialization_preserves_process_setting(self):
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",
})
settings = []
def create_client(**kwargs):
settings.append(os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
return mock.Mock()
with mock.patch.dict("os.environ", {
"AWS_REQUEST_CHECKSUM_CALCULATION": "WHEN_SUPPORTED"}, clear=True), \
mock.patch("pyarrow.fs.S3FileSystem", side_effect=create_client):
PyArrowFileIO("oss://test-bucket/", options)
self.assertEqual(
"WHEN_SUPPORTED",
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"])
self.assertEqual(["WHEN_SUPPORTED"], settings)
def test_initialization_sets_default_for_all_s3_schemes(self):
options = Options({
S3Options.S3_ENDPOINT.key(): "http://minio:9000",
})
for scheme in ("s3", "s3a", "s3n"):
settings = []
def create_client(**kwargs):
settings.append(os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
return mock.Mock()
with self.subTest(scheme=scheme), \
mock.patch.dict("os.environ", {}, clear=True), \
mock.patch("pyarrow.fs.S3FileSystem", side_effect=create_client):
PyArrowFileIO(
"{}://test-bucket/warehouse".format(scheme), options)
self.assertEqual(self._expected_checksum_default(),
os.environ.get("AWS_REQUEST_CHECKSUM_CALCULATION"))
self.assertEqual([self._expected_checksum_default()], settings)
def test_native_s3_does_not_change_checksum_setting(self):
with mock.patch.dict("os.environ", {}, clear=True), \
mock.patch("pyarrow.fs.S3FileSystem", return_value=mock.Mock()):
PyArrowFileIO("s3://test-bucket/warehouse", Options({}))
self.assertNotIn(
"AWS_REQUEST_CHECKSUM_CALCULATION", os.environ)
def test_worker_recomputes_pyarrow_version(self):
state = self._new_file_io().__getstate__()
state.update({
"_pyarrow_gte_8": False,
"_pyarrow_gte_16": False,
"_oss_bucket_in_endpoint": True,
})
restored = object.__new__(PyArrowFileIO)
with mock.patch.object(
PyArrowFileIO, "_initialize_s3_fs", return_value=object()):
restored.__setstate__(state)
version = parse(pyarrow.__version__)
self.assertEqual(version >= parse("8.0.0"), restored._pyarrow_gte_8)
self.assertEqual(version >= parse("16.0.0"), restored._pyarrow_gte_16)
self.assertEqual(version < parse("16.0.0"), restored._oss_bucket_in_endpoint)
def test_worker_accepts_old_non_oss_pickle(self):
state = self._new_file_io().__dict__.copy()
for key in ("_legacy_bucket_lock", "_is_s3",
"_s3_endpoint"):
state.pop(key)
state["filesystem"] = mock.Mock(spec=pafs.S3FileSystem)
restored = object.__new__(PyArrowFileIO)
with mock.patch.object(
PyArrowFileIO, "_initialize_s3_fs", return_value=object()
) as initialize:
restored.__setstate__(state)
self.assertTrue(restored._is_s3)
self.assertEqual("http://minio:9000", restored._s3_endpoint)
initialize.assert_called_once()
state["filesystem"] = pafs.LocalFileSystem()
restored = object.__new__(PyArrowFileIO)
restored.__setstate__(state)
self.assertFalse(restored._is_s3)
self.assertIsNone(restored._s3_endpoint)
@unittest.skipUnless(
parse(pyarrow.__version__) >= parse("22.0.0"),
"requires PyArrow 22+ optional request checksums",
)
def test_default_applies_to_parent_and_worker(self):
server = _HTTPServer(
("127.0.0.1", 0), _UploadHandler)
server.requests = []
server.put_headers = []
server.bucket_objects = {"test-bucket": set()}
server_thread = threading.Thread(target=server.serve_forever)
server_thread.start()
try:
endpoint = "http://127.0.0.1:{}".format(server.server_port)
options = Options({
S3Options.S3_ACCESS_KEY_ID.key(): "ak",
S3Options.S3_ACCESS_KEY_SECRET.key(): "sk",
S3Options.S3_ENDPOINT.key(): endpoint,
S3Options.S3_REGION.key(): "us-east-1",
"fs.s3.path.style.access": "true",
})
with mock.patch.dict(os.environ, {
"NO_PROXY": "127.0.0.1,localhost",
"no_proxy": "127.0.0.1,localhost",
}):
compatible = PyArrowFileIO("s3://test-bucket/", options)
native = pafs.S3FileSystem(
access_key="ak", secret_key="sk", region="us-east-1",
endpoint_override=endpoint)
self.assertEqual("WHEN_REQUIRED",
os.environ["AWS_REQUEST_CHECKSUM_CALCULATION"])
with native.open_output_stream("test-bucket/file") as stream:
stream.write(b"x")
with compatible.filesystem.open_output_stream(
"test-bucket/file") as stream:
stream.write(b"x")
context = multiprocessing.get_context("spawn")
parent, child = context.Pipe()
process = context.Process(target=_write_in_worker, args=(child,))
process.start()
child.close()
try:
self.assertTrue(parent.poll(20))
self.assertEqual("ready", parent.recv())
parent.send_bytes(pickle.dumps(compatible))
self.assertTrue(parent.poll(20))
self.assertEqual("written", parent.recv())
finally:
parent.close()
process.join(20)
if process.is_alive():
process.terminate()
process.join()
self.assertEqual(0, process.exitcode)
self.assertEqual(3, len(server.put_headers))
for headers in server.put_headers:
self.assertNotIn("x-amz-trailer", {key.lower() for key in headers})
finally:
server.shutdown()
server.server_close()
server_thread.join()