blob: 251d4cc6f69de1daa71176d4844b6d4d0688d9bd [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.
"""Adapt a resolved public table to the optional Native execution runtime."""
import json
import os
from threading import Lock
from types import SimpleNamespace
from typing import Optional
from pypaimon.common.options.config import CatalogOptions, FuseOptions, OssOptions
from pypaimon.common.options.options_utils import OptionsUtils
def _native_blob_view_supported(table, read_names) -> bool:
"""Resolve Blob views natively only with the REST catalog environment."""
if (not (table.options.blob_view_fields() & set(read_names))
or not table.options.blob_view_resolve_enabled()):
return True
loader = getattr(getattr(table, 'catalog_environment', None), 'catalog_loader', None)
# Python also leaves view structs unresolved without a loader.
return loader is None or _catalog_metastore(loader) == 'rest'
def _partition_fields(table):
"""Ordered partition DataFields, used to decode the split partition bytes."""
schema = table.table_schema
by_name = {f.name: f for f in schema.fields}
return [by_name[name] for name in schema.partition_keys]
def _catalog_metastore(loader) -> Optional[str]:
"""Return the Rust catalog kind for an exact built-in loader."""
from pypaimon.catalog.filesystem_catalog_loader import FileSystemCatalogLoader
from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
# Subclasses may override load() with routing or option semantics which
# cannot be reproduced from context().options alone.
if type(loader) is FileSystemCatalogLoader:
return 'filesystem'
if type(loader) is RESTCatalogLoader:
return 'rest'
return None
def _option_value_to_string(value) -> str:
"""Stringify an option value for the Rust catalog.
Python bools stringify to ``'True'``/``'False'``; Rust parses booleans
case-sensitively, so emit lowercase ``'true'``/``'false'`` instead.
"""
if isinstance(value, bool):
return 'true' if value else 'false'
return OptionsUtils.convert_to_string(value)
def _catalog_options(table) -> dict:
"""Catalog options that built this table, to reconstruct the Rust catalog."""
loader = getattr(getattr(table, 'catalog_environment', None), 'catalog_loader', None)
if loader is None:
raise ValueError("native_plan requires a catalog-backed table (no catalog loader)")
metastore = _catalog_metastore(loader)
if metastore is None:
raise ValueError("native_plan requires an exact built-in catalog loader")
normalized = _catalog_context_options(table)
normalized[CatalogOptions.METASTORE.key()] = metastore
return normalized
def _catalog_context_options(table) -> dict:
"""Normalize catalog storage properties without rebuilding the metastore."""
loader = table.catalog_environment.catalog_loader
context_options = loader.context().options
options = context_options.to_map()
normalized = {
str(key): _option_value_to_string(value)
for key, value in options.items()
if value is not None
}
if FuseOptions.FUSE_ENABLED.key() in normalized:
normalized[FuseOptions.FUSE_ENABLED.key()] = _option_value_to_string(
context_options.get(FuseOptions.FUSE_ENABLED))
if str(getattr(table, 'table_path', '')).startswith('oss://'):
from pypaimon.filesystem.jindo_file_system_handler import (
JINDO_AVAILABLE,
)
impl = normalized.get(OssOptions.OSS_IMPL.key())
if JINDO_AVAILABLE and (impl is None or impl.lower() == 'jindo'):
# This catalog is only used for Rust scan planning.
normalized[OssOptions.OSS_IMPL.key()] = 'jindo'
return normalized
def _resolved_schema_file_io_options(table) -> Optional[dict]:
"""FileIO properties for tables whose metadata needs no catalog resolution."""
environment = table.catalog_environment
loader = environment.catalog_loader
if loader is None:
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
# A custom environment or FileIO can supply metadata outside the path.
if (type(environment) is not CatalogEnvironment
or type(table.file_io) not in (LocalFileIO, PyArrowFileIO, ResolvingFileIO)):
return None
return {str(key): _option_value_to_string(value)
for key, value in table.file_io.properties.to_map().items()
if value is not None}
from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader
if _catalog_metastore(loader) != 'filesystem' and type(loader) is not JdbcCatalogLoader:
# REST tables must retain catalog snapshot loading and token refresh.
return None
context = loader.context()
if context.options is None:
return None
# JDBC, like filesystem catalogs, uses on-disk snapshots. Its already
# resolved table does not need another database connection during planning.
return _catalog_context_options(table)
def _resolved_schema_json(table) -> str:
"""Preserve all effective table options as strings for Rust."""
from pypaimon.common.json_util import JSON
options = {str(key): _option_value_to_string(value)
for key, value in table.table_schema.options.items() if value is not None}
if 'query-auth.enabled' in options:
# Preserve Python's effective policy. Forwarding an enabled spelling
# such as "on" unchanged would disable authorization in Rust.
options['query-auth.enabled'] = _option_value_to_string(OptionsUtils.convert_to_boolean(
table.table_schema.options['query-auth.enabled']))
return JSON.to_json(table.table_schema.copy(new_options=options))
def _resolved_rest_table_response(table, identifier=None):
"""Reuse REST metadata only when the standard loader can be reproduced."""
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
environment = table.catalog_environment
identifier = table.identifier if identifier is None else identifier
response = getattr(environment, 'rest_table_response', None)
if (type(environment) is not CatalogEnvironment
or type(environment.catalog_loader) is not RESTCatalogLoader
or not isinstance(response, str)):
return None
metadata = json.loads(response)
if (metadata.get('path') != table.table_path
or metadata.get('name') != identifier.get_object_name()
or ('database' in metadata
and metadata['database'] != identifier.get_database_name())):
return None
return response
class _NativeRestTableCache:
"""One process-local native table, discarded during worker serialization."""
def __init__(self):
self._states = {}
def __getstate__(self):
return {}
def __setstate__(self, state):
self.__init__()
def get_or_create(self, key, create):
pid = os.getpid()
# Publish a complete state atomically; never touch an inherited lock.
states = self._states
state = states.get(pid)
if state is None:
state = states.setdefault(pid, SimpleNamespace(lock=Lock(), entry=None))
self._states = {pid: state}
with state.lock:
if state.entry is None or state.entry[0] != key:
state.entry = (key, create())
return state.entry[1]
def get(self, response, database, table, options, token_loader=None):
from pypaimon_rust.datafusion import Table
def create():
token = token_loader() if token_loader else None
if token is None:
return Table.from_rest_response(
response, database=database, table=table, rest_options=options)
return Table.from_rest_response_with_token(
response, database=database, table=table, rest_options=options,
data_token=dict(token.token), expires_at_millis=token.expire_at_millis)
key = (response, database, table, tuple(sorted(options.items())))
return self.get_or_create(key, create)
def _rest_data_token(table):
"""Reuse only an existing valid token bound to this exact table."""
from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
from pypaimon.filesystem.caching_file_io import CachingFileIO
file_io = table.file_io
if type(file_io) is CachingFileIO:
file_io = file_io._delegate
if (table.current_branch() != 'main'
or type(file_io) is not RESTTokenFileIO
or file_io.identifier != table.identifier
or file_io.path != table.table_path):
return None
return file_io._existing_valid_token()
def _validate_native_fuse_binding(table):
"""Find the catalog table whose construction reproduces the bound FileIO."""
from pypaimon.catalog.rest.fuse_support import FusePathResolver
from pypaimon.common.identifier import Identifier
from pypaimon.filesystem.caching_file_io import CachingFileIO
from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO
file_io = table.file_io
if type(file_io) is CachingFileIO:
file_io = file_io._delegate
if not isinstance(file_io, FuseLocalFileIO):
return
loader = table.catalog_environment.catalog_loader
if type(file_io) is not FuseLocalFileIO or _catalog_metastore(loader) != 'rest':
raise RuntimeError('Native reads cannot reconstruct custom FUSE FileIO')
options = loader.context().options
if not options.get(FuseOptions.FUSE_ENABLED):
raise RuntimeError('Native catalog would disable the bound FUSE mount')
if file_io.path.rstrip('/') != table.table_path.rstrip('/'):
raise RuntimeError('The bound FUSE FileIO belongs to a different table path')
candidates = []
response = table.catalog_environment.rest_table_response
if isinstance(response, str):
metadata = json.loads(response)
if (metadata.get('path') == table.table_path
and metadata.get('database', table.identifier.get_database_name())
== table.identifier.get_database_name() and isinstance(metadata.get('name'), str)):
original = Identifier.create(table.identifier.get_database_name(), metadata['name'])
if original.get_table_name() == table.identifier.get_table_name():
candidates.append(original)
candidates.append(table.identifier)
# A manually resolved view can omit the original REST response. Its usual
# main-table binding can still be reconstructed by the same catalog.
candidates.append(Identifier.create(table.identifier.get_database_name(), table.identifier.get_table_name()))
resolver = FusePathResolver(options, None)
local = LocalFileIO()
mounted = local._to_file(file_io.fuse_path).absolute()
for identifier in candidates:
expected = resolver.resolve_local_path(table.table_path, identifier)
if mounted == local._to_file(expected).absolute():
return identifier
raise RuntimeError('Native catalog cannot reconstruct the bound FUSE FileIO')
def _native_table(table):
"""Transport the resolved schema, shared FileIO and captured read view."""
_validate_native_fuse_binding(table)
if ('_read_snapshot' in vars(table)
and _catalog_metastore(table.catalog_environment.catalog_loader) == 'rest'):
from pypaimon.common.json_util import JSON
environment = table.catalog_environment
resources = vars(table).setdefault('_pinned_read_resources', {})
cache = resources.setdefault('native_table', _NativeRestTableCache())
key = (table.table_path, table.identifier.get_full_name(), table.file_io,
environment.uuid, environment.rest_table_response,
tuple(sorted(_catalog_options(table).items())),
JSON.to_json(table._read_snapshot) if table._read_snapshot is not None else None)
# Keep core's verified snapshot alive for this captured view, including
# ordinary read-option copies. Pickling/forking starts a fresh cache.
native = cache.get_or_create(key, lambda: _load_native_table(table))
return _with_native_read_snapshot(table, _with_native_resolved_schema(table, native))
return _load_native_table(table)
def _load_native_table(table):
identifier = _validate_native_fuse_binding(table) or table.identifier
rest_response = _resolved_rest_table_response(table, identifier)
file_io_options = _resolved_schema_file_io_options(table)
if rest_response is not None:
cache = table.catalog_environment.__dict__.setdefault(
'_native_rest_table_cache', _NativeRestTableCache())
rt = cache.get(
rest_response,
database=identifier.get_database_name(),
table=identifier.get_object_name(),
options=_catalog_options(table),
token_loader=lambda: _rest_data_token(table))
elif file_io_options is not None:
from pypaimon_rust.datafusion import Table
rt = Table.from_resolved_schema(
table.table_path, _resolved_schema_json(table),
database=table.identifier.get_database_name(),
table=table.identifier.get_table_name(),
branch=table.current_branch(), options=file_io_options)
else:
from pypaimon_rust.datafusion import PaimonCatalog
# The Python loader has already resolved /config, including typed
# FUSE options. Match RESTCatalogLoader.load() and reuse that context.
catalog = PaimonCatalog(_catalog_options(table), config_required=False)
# REST may keep branch schemas in the catalog only. Load the base
# environment, then attach the schema/branch already resolved here.
rt = catalog.get_table((
identifier.get_database_name(), identifier.get_object_name()))
if rt.location() != table.table_path:
raise RuntimeError('Native catalog resolved a different table location')
if identifier != table.identifier:
# Construct the originally bound table, then use the existing copy API.
# Core retains its FileIO while resolving the branch's REST identity.
rt = rt.copy({'branch': table.current_branch()})
if file_io_options is None:
rt = _with_native_resolved_schema(table, rt)
if table.current_branch() != 'main':
if rt.branch() != table.current_branch():
raise RuntimeError("Native table did not resolve the requested branch")
return _with_native_read_snapshot(table, rt)
def _with_native_resolved_schema(table, native_table):
# Resolve time travel through core before replacing options. Caller-supplied
# fields cannot acquire the catalog's authorization session.
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
resolved = _resolved_schema_json(table)
options = json.loads(resolved)['options']
historical = (options.get('query-auth.enabled') == 'true'
and any(key in options for key in SCAN_KEYS))
snapshot = vars(table).get('_read_snapshot')
if historical and snapshot is not None:
from pypaimon.common.json_util import JSON
# A captured view can intentionally retain the current read schema
# while its files were written under an older snapshot schema.
native_table = native_table.copy_with_pinned_snapshot(
JSON.to_json(snapshot), with_schema=snapshot.schema_id == table.table_schema.id)
elif historical:
native_table = native_table.copy(options)
return native_table.copy_with_resolved_schema(resolved, branch=table.current_branch())
def _with_native_read_snapshot(table, native_table):
"""Attach a captured read view, including a fixed empty snapshot."""
# Only explicitly stored state captures a view; None means a fixed empty view.
table_state = vars(table)
if '_read_snapshot' in table_state:
from pypaimon.common.json_util import JSON
snapshot = table_state['_read_snapshot']
native_table = native_table.copy_with_pinned_snapshot(JSON.to_json(snapshot) if snapshot is not None else None)
return native_table
def _native_query_auth_supported(table):
"""Only reproduce authorization owned by the standard REST environment."""
from pypaimon.catalog.catalog_environment import CatalogEnvironment, _TableQueryAuthFn
environment = table.catalog_environment
auth = environment.table_query_auth(table.options, table.identifier)
if auth is None:
return True
if (type(environment) is not CatalogEnvironment
or type(auth) is not _TableQueryAuthFn
or auth._catalog_loader is not environment.catalog_loader
or auth._identifier != table.identifier
or not table.options.query_auth_enabled
or _catalog_metastore(environment.catalog_loader) != 'rest'):
return False
return True