blob: efbfe7f1899dc3b2ee887a7b62eb7cda5d603963 [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.
import logging
import pyarrow as pa
from pyarrow import PythonFile
from pyarrow._fs import FileSystemHandler
from pyarrow.fs import FileInfo, FileSelector, FileType
# `JindoFileSystemHandler` (the PyArrow FileIO path) only needs `pyjindo.fs`
# and `pyjindo.util`. The PVFS jindo backend (`create_jindo_oss_filesystem`)
# additionally needs `pyjindo.ossfs`. Track the two surfaces independently so
# that a pyjindosdk build without `pyjindo.ossfs` does not silently disable
# the previously-working PyArrow path.
try:
import pyjindo.fs as jfs
import pyjindo.util as jutil
JINDO_AVAILABLE = True
except ImportError:
JINDO_AVAILABLE = False
jfs = None
jutil = None
try:
import pyjindo.ossfs as jossfs
JINDO_OSSFS_AVAILABLE = True
except ImportError:
jossfs = None
JINDO_OSSFS_AVAILABLE = False
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions
def build_jindo_config(catalog_options: Options):
"""Build a pyjindo ``Config`` from OSS catalog options.
Shared by ``JindoFileSystemHandler`` (the PyArrow FileIO path) and
``create_jindo_oss_filesystem`` (the PVFS fsspec path) so both jindo entry
points consume exactly the same credential / endpoint options.
"""
if not JINDO_AVAILABLE:
raise ImportError("Module pyjindo is not available. Please install pyjindosdk.")
config = jutil.Config()
access_key_id = catalog_options.get(OssOptions.OSS_ACCESS_KEY_ID)
access_key_secret = catalog_options.get(OssOptions.OSS_ACCESS_KEY_SECRET)
security_token = catalog_options.get(OssOptions.OSS_SECURITY_TOKEN)
endpoint = catalog_options.get(OssOptions.OSS_ENDPOINT)
region = catalog_options.get(OssOptions.OSS_REGION)
if access_key_id:
config.set("fs.oss.accessKeyId", access_key_id)
if access_key_secret:
config.set("fs.oss.accessKeySecret", access_key_secret)
if security_token:
config.set("fs.oss.securityToken", security_token)
if endpoint:
endpoint_clean = endpoint.replace('http://', '').replace('https://', '')
config.set("fs.oss.endpoint", endpoint_clean)
if region:
config.set("fs.oss.region", region)
config.set("fs.oss.user.agent.features", "pypaimon")
return config
def create_jindo_oss_filesystem(root_uri: str, catalog_options: Options):
"""Create an fsspec-compatible ``JindoOssFileSystem`` for an OSS bucket.
``PaimonVirtualFileSystem`` uses this to back OSS reads/writes with the
native JindoSDK instead of ``ossfs``. JindoSDK writes objects via
PutObject / multipart upload, so it never issues OSS ``AppendObject`` --
the call that fails with ``PositionNotEqualToLength`` (409) on the OSS
data-acceleration endpoint when ``ossfs`` flushes a multi-chunk write.
``root_uri`` is the bucket root, e.g. ``oss://my-bucket/``; it must carry
the bucket so ``JindoOssFileSystem`` can re-attach the ``oss://`` scheme to
the bucket-relative paths that ``PaimonVirtualFileSystem`` passes in.
"""
if not (JINDO_AVAILABLE and JINDO_OSSFS_AVAILABLE):
raise ImportError(
"pyjindo.ossfs is not available. Please install pyjindosdk>=6.10.4."
)
return jossfs.JindoOssFileSystem(
uri=root_uri,
config=build_jindo_config(catalog_options),
# PaimonVirtualFileSystem owns directory semantics for the virtual FS;
# the backing object-store fs must not auto-create dir-marker objects.
auto_mkdir=False,
# Bypass fsspec's _Cached metaclass instance cache, so the only
# reference to this filesystem -- and to its underlying native jindo
# connection -- is the PaimonRealStorage cache in PVFS. On token
# refresh PVFS replaces that entry and the native resources can be
# released, instead of being pinned forever by fsspec's global cache.
skip_instance_cache=True,
)
class JindoInputFile:
def __init__(self, jindo_stream):
self._stream = jindo_stream
self._closed = False
@property
def closed(self):
if hasattr(self._stream, 'closed'):
return self._stream.closed
return self._closed
def read(self, nbytes: int = -1):
if self.closed:
raise ValueError("I/O operation on closed file")
if nbytes is None or nbytes < 0:
return self._stream.read()
return self._stream.read(nbytes)
def seek(self, position: int, whence: int = 0):
if self.closed:
raise ValueError("I/O operation on closed file")
self._stream.seek(position, whence)
def tell(self) -> int:
if self.closed:
raise ValueError("I/O operation on closed file")
return self._stream.tell()
def read_at(self, nbytes: int, offset: int):
if self.closed:
raise ValueError("I/O operation on closed file")
return self._stream.pread(nbytes, offset)
def close(self):
if not self._closed:
self._stream.close()
self._closed = True
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
class JindoOutputFile:
def __init__(self, jindo_stream):
self._stream = jindo_stream
self._closed = False
@property
def closed(self):
if hasattr(self._stream, 'closed'):
return self._stream.closed
return self._closed
def write(self, data: bytes) -> int:
if self.closed:
raise ValueError("I/O operation on closed file")
if isinstance(data, pa.Buffer):
data = data.to_pybytes()
elif not isinstance(data, bytes):
raise TypeError("Unsupported data type")
return self._stream.write(data)
def flush(self):
if self.closed:
raise ValueError("I/O operation on closed file")
if hasattr(self._stream, 'flush'):
self._stream.flush()
def close(self):
if not self._closed:
self._stream.close()
self._closed = True
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
class JindoFileSystemHandler(FileSystemHandler):
def __init__(self, root_path: str, catalog_options: Options):
if not JINDO_AVAILABLE:
raise ImportError("Module pyjindo is not available. Please install pyjindosdk.")
self.logger = logging.getLogger(__name__)
self.root_path = root_path
self.properties = catalog_options
config = build_jindo_config(catalog_options)
self._jindo_fs = jfs.connect(self.root_path, "root", config)
def __eq__(self, other):
if isinstance(other, JindoFileSystemHandler):
return self.root_path == other.root_path
return NotImplemented
def __ne__(self, other):
if isinstance(other, JindoFileSystemHandler):
return not self.__eq__(other)
return NotImplemented
def _normalize_path(self, path: str) -> str:
if path.startswith('oss://'):
return path
if not path or path == '.':
return self.root_path.rstrip('/') + '/'
path_clean = path.lstrip('/')
return self.root_path.rstrip('/') + '/' + path_clean
def _convert_file_type(self, jindo_type) -> FileType:
if jindo_type == jfs.FileType.File:
return FileType.File
elif jindo_type == jfs.FileType.Directory:
return FileType.Directory
else:
return FileType.Unknown
def _convert_file_info(self, jindo_info) -> FileInfo:
pa_type = self._convert_file_type(jindo_info.type)
return FileInfo(
path=jindo_info.path,
type=pa_type,
size=jindo_info.size if jindo_info.type == jfs.FileType.File else None,
mtime=jindo_info.mtime if hasattr(jindo_info, 'mtime') else None,
)
def get_type_name(self) -> str:
return "jindo"
def get_file_info(self, paths) -> list:
infos = []
for path in paths:
normalized = self._normalize_path(path)
try:
jindo_info = self._jindo_fs.get_file_info(normalized)
infos.append(self._convert_file_info(jindo_info))
except FileNotFoundError:
infos.append(FileInfo(normalized, FileType.NotFound))
return infos
def get_file_info_selector(self, selector: FileSelector) -> list:
normalized = self._normalize_path(selector.base_dir)
try:
items = self._jindo_fs.listdir(normalized, recursive=selector.recursive)
return [self._convert_file_info(item) for item in items]
except FileNotFoundError:
if selector.allow_not_found:
return []
raise
def create_dir(self, path: str, recursive: bool):
normalized = self._normalize_path(path)
self._jindo_fs.mkdir(normalized)
def delete_dir(self, path: str):
normalized = self._normalize_path(path)
self._jindo_fs.remove(normalized)
def delete_dir_contents(self, path: str, missing_dir_ok: bool = False):
normalized = self._normalize_path(path)
if normalized == self.root_path:
raise ValueError(
"delete_dir_contents() does not accept root path"
)
self._delete_dir_contents(path, missing_dir_ok)
def delete_root_dir_contents(self):
self._delete_dir_contents("/", missing_dir_ok=False)
def _delete_dir_contents(self, path: str, missing_dir_ok: bool):
normalized = self._normalize_path(path)
try:
items = self._jindo_fs.listdir(normalized, recursive=False)
except FileNotFoundError:
if missing_dir_ok:
return
raise
except Exception as e:
self.logger.warning(f"Error listing {path}: {e}")
raise
for item in items:
self._jindo_fs.remove(item.path)
def delete_file(self, path: str):
normalized = self._normalize_path(path)
self._jindo_fs.remove(normalized)
def move(self, src: str, dest: str):
src_norm = self._normalize_path(src)
dst_norm = self._normalize_path(dest)
self._jindo_fs.rename(src_norm, dst_norm)
def copy_file(self, src: str, dest: str):
src_norm = self._normalize_path(src)
dst_norm = self._normalize_path(dest)
self._jindo_fs.copy_file(src_norm, dst_norm)
def open_input_stream(self, path: str):
normalized = self._normalize_path(path)
jindo_stream = self._jindo_fs.open(normalized, "rb")
return PythonFile(JindoInputFile(jindo_stream), mode="r")
def open_input_file(self, path: str):
normalized = self._normalize_path(path)
jindo_stream = self._jindo_fs.open(normalized, "rb")
return PythonFile(JindoInputFile(jindo_stream), mode="r")
def open_output_stream(self, path: str, metadata):
normalized = self._normalize_path(path)
jindo_stream = self._jindo_fs.open(normalized, "wb")
return PythonFile(JindoOutputFile(jindo_stream), mode="w")
def open_append_stream(self, path: str, metadata):
raise IOError("append mode is not supported")
def normalize_path(self, path: str) -> str:
return self._normalize_path(path)