blob: 60ece775c9206a2aa6fa5bad5140385217b0f04f [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.
"""Plan splits with pypaimon_rust, decoded for the normal pypaimon reader.
Optional, lazily-imported dependency; enabled by ``scan.native-plan.enabled``.
Predicates and limits are pushed into Rust planning. The normal pypaimon reader
still applies them while reading, so pushdown remains an optimization.
"""
import json
import os
from threading import Lock
from types import SimpleNamespace
from typing import List, Optional, Tuple
from packaging.version import InvalidVersion, Version
from pypaimon.common.options.config import CatalogOptions, OssOptions
from pypaimon.common.options.options_utils import OptionsUtils
from pypaimon.common.predicate import Predicate
from pypaimon.read.plan import Plan
from pypaimon.read.split_serializer import (
deserialize_split_v1, serialize_split_v1)
from pypaimon.schema.data_types import DataField, RowType
def _raise_if_native_fork_safety_error(error):
"""Propagate native fork errors instead of using inherited SDK state."""
try:
from pypaimon_rust import ForkSafetyError
except ImportError:
return
current = error
seen = set()
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, ForkSafetyError):
raise current
current = current.__cause__ or current.__context__
def native_runtime_available() -> bool:
"""Whether an installed pypaimon-rust exposes the full split-planning API.
Both entry points used by :func:`native_plan` are probed: ``PaimonCatalog.
get_table`` (0.3.0) and ``Split.serialize`` (the split wire format). An
intermediate build missing either must fall back, not fail mid-plan.
"""
try:
from pypaimon_rust.datafusion import PaimonCatalog, Split
except ImportError:
return False
return hasattr(PaimonCatalog, 'get_table') and hasattr(Split, 'serialize')
def native_reader_available() -> bool:
"""Whether pypaimon-rust exposes the batch-read API used by PyPaimon."""
return (native_runtime_available()
and native_method_available('ReadBuilder', 'new_read')
and native_method_available('TableRead', 'read'))
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 native_split_bridge_available() -> bool:
"""Whether Rust accepts Java-compatible split bytes from Python plans."""
return native_reader_available() and native_method_available(
'Split', 'deserialize')
def native_split_from_python(split):
"""Convert a Python-planned DataSplit/IndexedSplit for the Rust reader.
Vector scores intentionally stay on the Python IndexedSplit. Rust needs
only its row ranges to perform the physical read.
"""
from pypaimon_rust.datafusion import Split as NativeSplit
return NativeSplit.deserialize(
serialize_split_v1(split, include_scores=False))
def native_family_search_modes_available() -> bool:
"""Whether Rust supports family-specific global-index search modes."""
return native_version_at_least(0, 4)
def native_version_at_least(major: int, minor: int, patch: int = 0) -> bool:
"""Compare complete package versions, including patch and pre-release ordering."""
if not native_runtime_available():
return False
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError:
return False
try:
rust_version = Version(version('pypaimon-rust'))
except (PackageNotFoundError, InvalidVersion):
return False
return rust_version >= Version('%d.%d.%d' % (major, minor, patch))
def native_method_available(type_name: str, method: str) -> bool:
try:
from pypaimon_rust import datafusion
return callable(getattr(getattr(datafusion, type_name, None), method, None))
except ImportError:
return False
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
options = loader.context().options.to_map()
normalized = {
str(key): _option_value_to_string(value)
for key, value in options.items()
if value is not None
}
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 or any(getattr(context, attr, None) is not None for attr in (
'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')):
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}
return JSON.to_json(table.table_schema.copy(new_options=options))
def _predicate_to_native(predicate: Predicate) -> dict:
"""Convert PyPaimon's predicate tree to pypaimon-rust's dict API."""
if predicate.method in ('and', 'or'):
children = predicate.literals or []
if not children:
raise ValueError("Native compound predicate requires children")
return {
'method': predicate.method,
'children': [_predicate_to_native(child) for child in children],
}
return {
'method': predicate.method,
'field': predicate.field,
'literals': list(predicate.literals or []),
}
def _resolved_rest_table_response(table):
"""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
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)
or not native_method_available('Table', 'from_rest_response')):
return None
context = environment.catalog_loader.context()
if any(getattr(context, attr, None) is not None for attr in (
'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')):
return None
metadata = json.loads(response)
if (metadata.get('path') != table.table_path
or metadata.get('name') != table.identifier.get_object_name()
or ('database' in metadata
and metadata['database'] != table.identifier.get_database_name())):
return None
return response
class _NativeRestTableCache:
"""One native environment per Python environment, never sent to workers."""
def __init__(self):
self._states = {}
def __getstate__(self):
return {}
def __setstate__(self, state):
self.__init__()
def get(self, response, database, table, options):
from pypaimon_rust.datafusion import Table
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}
key = (response, database, table, tuple(sorted(options.items())))
with state.lock:
if state.entry is None or state.entry[0] != key:
native_table = Table.from_rest_response(
response, database=database, table=table, rest_options=options)
state.entry = (key, native_table)
return state.entry[1]
def _native_read_builder(table):
"""Return a fresh builder with the current schema and shared REST FileIO."""
rest_response = _resolved_rest_table_response(table)
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=table.identifier.get_database_name(),
table=table.identifier.get_object_name(),
options=_catalog_options(table))
rt = rt.copy_with_resolved_schema(_resolved_schema_json(table), branch=table.current_branch())
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
catalog = PaimonCatalog(_catalog_options(table))
# 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((
table.identifier.get_database_name(), table.identifier.get_table_name()))
if rt.location() != table.table_path:
raise RuntimeError('Native catalog resolved a different table location')
rt = rt.copy_with_resolved_schema(_resolved_schema_json(table), branch=table.current_branch())
if table.current_branch() != 'main':
branch = getattr(rt, 'branch', None)
if not callable(branch) or branch() != table.current_branch():
raise RuntimeError("Native table did not resolve the requested branch")
return rt.new_read_builder()
def _configure_native_read_builder(builder, predicate, limit, projection,
read_type: Optional[List[DataField]] = None,
include_row_kind=False):
if read_type is not None:
builder = builder.with_read_type(
json.dumps(RowType(True, read_type).to_dict()))
if projection is not None and read_type is None:
builder = builder.with_projection(projection)
if predicate is not None:
builder = builder.with_filter(_predicate_to_native(predicate))
if limit is not None:
builder = builder.with_limit(limit)
if include_row_kind:
builder = builder.with_include_row_kind(True)
return builder
def _prepare_native_read(table, predicate: Optional[Predicate] = None,
limit: Optional[int] = None,
projection: Optional[List[str]] = None,
blob_parallelism: Optional[int] = None,
read_type: Optional[List[DataField]] = None,
include_row_kind: bool = False):
"""Create one Rust reader reusable across split groups."""
if not native_reader_available():
raise RuntimeError(
"read.native.enabled needs the pypaimon-rust native reader API")
if read_type is None:
from pypaimon.read.read_builder import ReadBuilder
resolved = ReadBuilder(table)
if projection is not None:
resolved.with_projection(projection)
read_type = resolved.read_type()
builder = _configure_native_read_builder(
_native_read_builder(table), predicate, limit, projection,
read_type=read_type,
include_row_kind=include_row_kind)
if blob_parallelism is not None:
builder = builder.with_blob_parallelism(blob_parallelism)
reader = builder.new_read()
read_arrow = getattr(reader, 'read_arrow', None)
return read_arrow if callable(read_arrow) else reader.read
def native_read(table, splits, predicate: Optional[Predicate] = None,
limit: Optional[int] = None,
projection: Optional[List[str]] = None,
blob_parallelism: Optional[int] = None,
read_type: Optional[List[DataField]] = None,
include_row_kind: bool = False):
"""Read Rust ``Split`` objects into PyArrow ``RecordBatch`` objects."""
read_splits = _prepare_native_read(
table,
predicate=predicate,
limit=limit,
projection=projection,
blob_parallelism=blob_parallelism,
read_type=read_type,
include_row_kind=include_row_kind)
return read_splits(splits)
def native_plan(
table,
predicate: Optional[Predicate] = None,
limit: Optional[int] = None,
projection: Optional[List[str]] = None,
row_ranges: Optional[List[Tuple[int, int]]] = None,
incremental_range: Optional[Tuple[int, int]] = None,
incremental_mode: str = 'delta',
row_position_slice: Optional[Tuple[int, int]] = None,
row_position_shard: Optional[Tuple[int, int]] = None,
chunk_shuffle: Optional[Tuple[int, int]] = None,
shard: Optional[Tuple[int, int]] = None) -> Plan:
"""Plan with pypaimon_rust, preserving snapshot metadata.
Native conversion or planning failures are handled by TableScan, which
falls back to the Python planner.
"""
if incremental_range is None and incremental_mode != 'delta':
raise ValueError('incremental_mode requires incremental_range')
if not native_runtime_available():
raise RuntimeError(
"scan.native-plan.enabled needs pypaimon-rust>=0.3.0 (split planning API)")
builder = _configure_native_read_builder(
_native_read_builder(table), predicate, limit, projection)
if row_ranges is not None:
builder = builder.with_row_ranges(row_ranges)
if incremental_range is None:
scan = builder.new_scan()
elif incremental_mode == 'delta':
# Keep the two-argument call compatible with runtimes predating the
# explicit mode API. Non-delta modes require the new binding.
scan = builder.new_incremental_scan(*incremental_range)
else:
scan = builder.new_incremental_scan(
*incremental_range, incremental_mode)
if row_position_slice is not None:
scan = scan.with_row_position_slice(*row_position_slice)
if row_position_shard is not None:
scan = scan.with_row_position_shard(*row_position_shard)
if chunk_shuffle is not None:
seed, chunk_size = chunk_shuffle
scan = scan.with_chunk_shuffle(str(seed), chunk_size)
if shard is not None:
scan = scan.with_shard(*shard)
rust_plan = scan.plan()
rust_splits = rust_plan.splits()
pfields = _partition_fields(table)
# Trimmed primary keys decode per-file min/max keys (PK merge-on-read).
kfields = table.trimmed_primary_keys_fields
splits = [
deserialize_split_v1(split.serialize(), pfields, kfields)
for split in rust_splits
]
if table.options.native_read_enabled():
# Retain the opaque Rust split next to the Python metadata view so an
# unchanged native plan can be read without reserializing each split.
# A caller that needs different metadata (such as an endpoint DV)
# passes a new Python split, which the native reader converts at read
# time from its current fields.
for split, rust_split in zip(splits, rust_splits):
split._native_split = rust_split
snapshot_id = getattr(rust_plan, 'snapshot_id', None)
if callable(snapshot_id):
snapshot_id = snapshot_id()
elif splits:
snapshot_id = splits[0].snapshot_id
else:
# Older bindings cannot distinguish an empty committed snapshot from a
# table without snapshots. Let the Python scanner recover the metadata.
raise RuntimeError("Native runtime cannot report an empty plan's snapshot")
return Plan(splits, snapshot_id=snapshot_id)