This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 875cfa771d [python] Support plan by paimon-rust (#8523)
875cfa771d is described below
commit 875cfa771d2c4ee47474d254d5ae455a237487c7
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Jul 20 13:01:07 2026 +0800
[python] Support plan by paimon-rust (#8523)
---
.github/workflows/paimon-python-checks.yml | 10 +-
.../pypaimon/common/options/core_options.py | 12 +
.../pypaimon/globalindex/indexed_split.py | 5 +
paimon-python/pypaimon/read/explain.py | 6 +
paimon-python/pypaimon/read/native_plan.py | 124 +++++++
paimon-python/pypaimon/read/read_builder.py | 13 +-
paimon-python/pypaimon/read/split.py | 8 +-
paimon-python/pypaimon/read/split_serializer.py | 304 +++++++++++++++++
paimon-python/pypaimon/read/table_scan.py | 145 +++++++-
paimon-python/pypaimon/table/file_store_table.py | 8 +-
.../pypaimon/tests/native_plan_integration_test.py | 230 +++++++++++++
paimon-python/pypaimon/tests/native_plan_test.py | 379 +++++++++++++++++++++
.../pypaimon/tests/split_serializer_test.py | 170 +++++++++
paimon-python/setup.py | 2 +-
14 files changed, 1405 insertions(+), 11 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index af7c7883fa..c3b35b7f4c 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -36,6 +36,7 @@ env:
JDK_VERSION: 8
MAVEN_OPTS: -Dmaven.wagon.httpconnectionManager.ttlSeconds=30
-Dmaven.wagon.http.retryHandler.requestSentEnabled=true
LUMINA_DATA_VERSION: 0.1.0
+ PYPAIMON_RUST_REV: 7c568274a4c5df6bd00e8d60edea21395412202b
concurrency:
@@ -142,7 +143,14 @@ jobs:
else
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
- python -m pip install pyroaring readerwriterlock==1.0.9
fsspec==2024.3.1 cachetools==5.3.3 ossfs==2023.12.0 ray==2.54.0
fastavro==1.11.1 'isal>=1.8,<2' pyarrow==16.0.0 zstandard==0.24.0
polars==1.32.0 duckdb==1.3.2 numpy==1.24.3 pandas==2.0.3 pylance==0.39.0
cramjam flake8==4.0.1 pytest~=7.0 py4j==0.10.9.9 requests parameterized==0.9.0
'daft>=0.7.6' pypaimon-rust==0.2.0 'datafusion>=52'
+ python -m pip install pyroaring readerwriterlock==1.0.9
fsspec==2024.3.1 cachetools==5.3.3 ossfs==2023.12.0 ray==2.54.0
fastavro==1.11.1 'isal>=1.8,<2' pyarrow==16.0.0 zstandard==0.24.0
polars==1.32.0 duckdb==1.3.2 numpy==1.24.3 pandas==2.0.3 pylance==0.39.0
cramjam flake8==4.0.1 pytest~=7.0 py4j==0.10.9.9 requests parameterized==0.9.0
'daft>=0.7.6' 'datafusion>=52'
+ if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
+ # Exercise the split-planning API in one lane until the
compatible 0.3 wheel is published.
+ python -m pip install
"git+https://github.com/apache/paimon-rust.git@${PYPAIMON_RUST_REV}#subdirectory=bindings/python"
+ python -c "from pypaimon_rust.datafusion import PaimonCatalog;
assert hasattr(PaimonCatalog, 'get_table')"
+ else
+ python -m pip install pypaimon-rust==0.2.0
+ fi
python -m pip install 'lumina-data>=${{ env.LUMINA_DATA_VERSION
}}' -i https://pypi.org/simple/
if python -c "import sys; sys.exit(0 if sys.version_info >= (3,
11) else 1)"; then
python -m pip install vortex-data==0.70.0
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index 4d2e6a7bb8..353c140ff5 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -533,6 +533,15 @@ class CoreOptions:
.with_description("Whether to enable deletion vectors.")
)
+ SCAN_NATIVE_PLAN_ENABLED: ConfigOption[bool] = (
+ ConfigOptions.key("scan.native-plan.enabled")
+ .boolean_type()
+ .default_value(False)
+ .with_description("Plan splits via the native (pypaimon_rust) planner "
+ "instead of the Python manifest scanner; the
pypaimon "
+ "reader still reads the files.")
+ )
+
CHANGELOG_PRODUCER: ConfigOption[ChangelogProducer] = (
ConfigOptions.key("changelog-producer")
.enum_type(ChangelogProducer)
@@ -1225,6 +1234,9 @@ class CoreOptions:
def deletion_vectors_enabled(self, default=None):
return self.options.get(CoreOptions.DELETION_VECTORS_ENABLED, default)
+ def native_plan_enabled(self, default=None):
+ return self.options.get(CoreOptions.SCAN_NATIVE_PLAN_ENABLED, default)
+
def changelog_producer(self, default=None):
return self.options.get(CoreOptions.CHANGELOG_PRODUCER, default)
diff --git a/paimon-python/pypaimon/globalindex/indexed_split.py
b/paimon-python/pypaimon/globalindex/indexed_split.py
index 0eaa09ebd4..18b75a0af0 100644
--- a/paimon-python/pypaimon/globalindex/indexed_split.py
+++ b/paimon-python/pypaimon/globalindex/indexed_split.py
@@ -100,6 +100,11 @@ class IndexedSplit(Split):
"""Delegate to data_split."""
return self._data_split.data_deletion_files
+ @property
+ def snapshot_id(self):
+ """Delegate to data_split."""
+ return self._data_split.snapshot_id
+
def contains_row_id(self, row_id: int) -> bool:
"""Check if the given row ID is in the row ranges."""
for r in self._row_ranges:
diff --git a/paimon-python/pypaimon/read/explain.py
b/paimon-python/pypaimon/read/explain.py
index 29deae951f..5cc1cb1bd8 100644
--- a/paimon-python/pypaimon/read/explain.py
+++ b/paimon-python/pypaimon/read/explain.py
@@ -130,6 +130,10 @@ class ExplainResult:
# Verbose-only
splits: Optional[List[ExplainSplitInfo]] = None
+ # Native (pypaimon_rust) plan; pruning funnel not tracked. Kept last to
+ # preserve positional-arg compatibility.
+ native_planned: bool = False
+
def __str__(self) -> str:
return render_explain(self)
@@ -163,6 +167,8 @@ def render_explain(result: ExplainResult) -> str:
_line(out, "Limit", str(result.limit) if result.limit is not None else
"<none>")
out.write("\n")
+ if result.native_planned:
+ _line(out, "Planner", "native (pypaimon_rust); pruning not tracked")
_line(out, "Partition pruning",
result.partition_pruning.format() if result.partition_pruning else
"n/a")
_line(out, "Bucket pruning",
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
new file mode 100644
index 0000000000..91f9827940
--- /dev/null
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -0,0 +1,124 @@
+# 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``.
+The predicate is applied pypaimon-side (partition pruning + row/limit filter),
+so results match the normal path.
+"""
+
+from typing import List, Optional
+
+from pypaimon.common.options.config import CatalogOptions
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options_utils import OptionsUtils
+from pypaimon.read.split import Split
+from pypaimon.read.split_serializer import deserialize_split_v1
+
+
+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 _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)")
+ 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
+ }
+ metastore = _catalog_metastore(loader)
+ if metastore is None:
+ raise ValueError("native_plan requires an exact built-in catalog
loader")
+ normalized[CatalogOptions.METASTORE.key()] = metastore
+ return normalized
+
+
+def _read_options(table) -> dict:
+ """Effective split-shaping options, including FileStoreTable.copy
overrides."""
+ return {
+ CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(): str(
+ table.options.source_split_target_size()),
+ CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(): str(
+ table.options.source_split_open_file_cost()),
+ }
+
+
+def native_plan(table) -> List[Split]:
+ """Plan with pypaimon_rust and return the decoded pypaimon splits.
+
+ Predicate/limit are not pushed to the native planner (pushdown is a
+ follow-up); pypaimon applies them at read time.
+ """
+ if not native_runtime_available():
+ raise RuntimeError(
+ "scan.native-plan.enabled needs pypaimon-rust>=0.3.0 (split
planning API)")
+ from pypaimon_rust.datafusion import PaimonCatalog
+
+ rt =
PaimonCatalog(_catalog_options(table)).get_table(table.identifier.get_full_name())
+ rust_splits =
rt.new_read_builder(_read_options(table)).new_scan().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
+ return [deserialize_split_v1(s.serialize(), pfields, kfields) for s in
rust_splits]
diff --git a/paimon-python/pypaimon/read/read_builder.py
b/paimon-python/pypaimon/read/read_builder.py
index b7eebc2ffd..a531d5d2c8 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -216,9 +216,15 @@ def _build_explain_result(table, scan: TableScan, plan,
stats: ScanStats,
table_schema = table.table_schema
bucket_mode_str = _safe_bucket_mode(table)
- partition_pruning = _partition_pruning(stats, scan)
- bucket_pruning = _bucket_pruning(stats, scan)
- file_skipping = _file_skipping(stats, scan)
+ # stats is None when planned natively (pypaimon_rust): no manifest pruning
+ # funnel is tracked, so the split-level signals below are all we can
report.
+ native_planned = stats is None
+ if native_planned:
+ partition_pruning = bucket_pruning = file_skipping = None
+ else:
+ partition_pruning = _partition_pruning(stats, scan)
+ bucket_pruning = _bucket_pruning(stats, scan)
+ file_skipping = _file_skipping(stats, scan)
files_per_split = [len(getattr(s, 'files', []) or []) for s in splits]
sizes = [int(getattr(s, 'file_size', 0) or 0) for s in splits]
@@ -314,6 +320,7 @@ def _build_explain_result(table, scan: TableScan, plan,
stats: ScanStats,
split_size_p50=sz_p50,
split_size_p95=sz_p95,
has_auth=plan_has_auth,
+ native_planned=native_planned,
splits=split_infos if verbose else None,
)
diff --git a/paimon-python/pypaimon/read/split.py
b/paimon-python/pypaimon/read/split.py
index c318a15b2c..bdfc537af1 100644
--- a/paimon-python/pypaimon/read/split.py
+++ b/paimon-python/pypaimon/read/split.py
@@ -78,13 +78,16 @@ class DataSplit(Split):
partition: GenericRow,
bucket: int,
raw_convertible: bool = False,
- data_deletion_files: Optional[List[DeletionFile]] = None
+ data_deletion_files: Optional[List[DeletionFile]] = None,
+ snapshot_id: Optional[int] = None
):
self._files = files
self._partition = partition
self._bucket = bucket
self.raw_convertible = raw_convertible
self.data_deletion_files = data_deletion_files
+ # Scanned snapshot; None unless populated (e.g. by the native planner).
+ self.snapshot_id = snapshot_id
@property
def files(self) -> List[DataFileMeta]:
@@ -121,7 +124,8 @@ class DataSplit(Split):
partition=self._partition,
bucket=self._bucket,
raw_convertible=self.raw_convertible,
- data_deletion_files=filtered_data_deletion_files
+ data_deletion_files=filtered_data_deletion_files,
+ snapshot_id=self.snapshot_id
)
@property
diff --git a/paimon-python/pypaimon/read/split_serializer.py
b/paimon-python/pypaimon/read/split_serializer.py
new file mode 100644
index 0000000000..4b50c86c28
--- /dev/null
+++ b/paimon-python/pypaimon/read/split_serializer.py
@@ -0,0 +1,304 @@
+# 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.
+
+"""Deserialize the cross-language ``SplitSerializer`` v1 binary into a pypaimon
+:class:`DataSplit`.
+
+Mirror of the Java ``DataSplit#serialize`` (VERSION 8) frame wrapped in the
+``SplitSerializer`` v1 header, as produced by ``pypaimon_rust``'s
+``Split.serialize()``. Extracts the fields the reader needs, plus per-file
+min/max keys for PK merge-on-read; key/value stats (planning-only) stay empty.
+"""
+
+import struct
+from typing import List, Optional
+
+from pypaimon.data.timestamp import Timestamp
+from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.read.split import DataSplit, Split
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.binary_row import BinaryRow
+from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer
+from pypaimon.table.source.deletion_file import DeletionFile
+from pypaimon.utils.range import Range
+
+# Frame magics/versions, mirroring the Rust/Java constants.
+_SPLIT_SER_MAGIC = 0x53504C49545F5631 # "SPLIT_V1"
+_SPLIT_SER_VERSION = 1
+_TYPE_DATA_SPLIT = 1
+_TYPE_INDEXED_SPLIT = 3
+_INDEXED_SPLIT_MAGIC = -938472394838495695
+_INDEXED_SPLIT_VERSION = 1
+_SPLIT_MAGIC = -2394839472490812314
+_SPLIT_VERSION = 8
+
+_DFM_ARITY = 20
+
+
+def _f(idx, name, dtype):
+ return DataField(idx, name, dtype)
+
+
+# DataFileMeta 20-field layout (order/types mirror
DataFileMetaSerializer#toRow).
+# Fields 3/4 (min/max key) are decoded for PK tables; 5/6 (stats) stay unread.
+_DFM_FIELDS: List[DataField] = [
+ _f(0, '_FILE_NAME', AtomicType('STRING')),
+ _f(1, '_FILE_SIZE', AtomicType('BIGINT')),
+ _f(2, '_ROW_COUNT', AtomicType('BIGINT')),
+ _f(3, '_MIN_KEY', AtomicType('BYTES')),
+ _f(4, '_MAX_KEY', AtomicType('BYTES')),
+ _f(5, '_KEY_STATS', AtomicType('BYTES')),
+ _f(6, '_VALUE_STATS', AtomicType('BYTES')),
+ _f(7, '_MIN_SEQUENCE_NUMBER', AtomicType('BIGINT')),
+ _f(8, '_MAX_SEQUENCE_NUMBER', AtomicType('BIGINT')),
+ _f(9, '_SCHEMA_ID', AtomicType('BIGINT')),
+ _f(10, '_LEVEL', AtomicType('INT')),
+ _f(11, '_EXTRA_FILES', AtomicType('BYTES')), # BinaryArray<string>,
decoded below
+ _f(12, '_CREATION_TIME', AtomicType('BIGINT')), # compact millis; read
raw, wrap Timestamp
+ _f(13, '_DELETE_ROW_COUNT', AtomicType('BIGINT')),
+ _f(14, '_EMBEDDED_FILE_INDEX', AtomicType('BYTES')),
+ _f(15, '_FILE_SOURCE', AtomicType('TINYINT')),
+ _f(16, '_VALUE_STATS_COLS', AtomicType('BYTES')),
+ _f(17, '_EXTERNAL_PATH', AtomicType('STRING')),
+ _f(18, '_FIRST_ROW_ID', AtomicType('BIGINT')),
+ _f(19, '_WRITE_COLS', AtomicType('BYTES')),
+]
+
+# Arity is fixed by DataSplit VERSION 8; keep the field list and arity in
lockstep.
+assert len(_DFM_FIELDS) == _DFM_ARITY
+
+
+def _decode_str_array(b: Optional[bytes]) -> Optional[List[str]]:
+ """Decode a Paimon ``BinaryArray<string>`` (non-null elements)."""
+ if not b:
+ return [] if b == b'' else None
+ n = struct.unpack_from('<i', b, 0)[0]
+ header = 4 + ((n + 31) // 32) * 4 # count + null bitset
+ out = []
+ for k in range(n):
+ eo = header + k * 8
+ if b[eo + 7] & 0x80: # inline: value in first 7 bytes, len in low 7
bits of byte 7
+ length = b[eo + 7] & 0x7F
+ out.append(b[eo:eo + length].decode('utf-8'))
+ else: # pointer: (var_off << 32) | len
+ slot = struct.unpack_from('<q', b, eo)[0]
+ var_off = (slot >> 32) & 0xFFFFFFFF
+ length = slot & 0xFFFFFFFF
+ out.append(b[var_off:var_off + length].decode('utf-8'))
+ return out
+
+
+def _decode_modified_utf8(raw: bytes) -> str:
+ """Decode Java writeUTF modified UTF-8 (NUL as C0 80, 3-byte for >= U+0800;
+ supplementary chars as a UTF-16 surrogate pair, recombined below)."""
+ units = []
+ i, n = 0, len(raw)
+ while i < n:
+ b = raw[i]
+ if b < 0x80:
+ units.append(b)
+ i += 1
+ elif b & 0xE0 == 0xC0:
+ units.append(((b & 0x1F) << 6) | (raw[i + 1] & 0x3F))
+ i += 2
+ else: # 0xE0: 3-byte
+ units.append(((b & 0x0F) << 12) | ((raw[i + 1] & 0x3F) << 6) |
(raw[i + 2] & 0x3F))
+ i += 3
+ out = []
+ j, m = 0, len(units)
+ while j < m:
+ c = units[j]
+ if 0xD800 <= c <= 0xDBFF and j + 1 < m and 0xDC00 <= units[j + 1] <=
0xDFFF:
+ out.append(0x10000 + ((c - 0xD800) << 10) + (units[j + 1] -
0xDC00))
+ j += 2
+ else:
+ out.append(c)
+ j += 1
+ return ''.join(chr(c) for c in out)
+
+
+class _Reader:
+ def __init__(self, data: bytes):
+ self.d = data
+ self.p = 0
+
+ def i32(self) -> int:
+ v = struct.unpack_from('>i', self.d, self.p)[0]
+ self.p += 4
+ return v
+
+ def i64(self) -> int:
+ v = struct.unpack_from('>q', self.d, self.p)[0]
+ self.p += 8
+ return v
+
+ def f32(self) -> float:
+ v = struct.unpack_from('>f', self.d, self.p)[0]
+ self.p += 4
+ return v
+
+ def u8(self) -> int:
+ v = self.d[self.p]
+ self.p += 1
+ return v
+
+ def take(self, n: int) -> bytes:
+ v = self.d[self.p:self.p + n]
+ self.p += n
+ return v
+
+ def java_utf(self) -> str:
+ n = struct.unpack_from('>H', self.d, self.p)[0]
+ self.p += 2
+ return _decode_modified_utf8(self.take(n))
+
+
+def deserialize_split_v1(data: bytes, partition_fields: List[DataField],
+ key_fields: Optional[List[DataField]] = None) ->
Split:
+ """Rebuild a pypaimon ``DataSplit`` (or ``IndexedSplit``) from
``Split.serialize()`` bytes.
+
+ ``key_fields`` (trimmed primary keys) decode per-file min/max keys for PK
+ merge-on-read; None for append tables.
+ """
+ r = _Reader(data)
+ magic = r.i64()
+ if magic != _SPLIT_SER_MAGIC:
+ raise ValueError("bad SplitSerializer magic %d" % magic)
+ version = r.i32()
+ if version != _SPLIT_SER_VERSION:
+ raise ValueError(
+ "unsupported SplitSerializer version %d (expected %d)" % (version,
_SPLIT_SER_VERSION))
+ type_id = r.i32()
+ if type_id == _TYPE_DATA_SPLIT:
+ return _read_datasplit_body(r, partition_fields, key_fields)
+ if type_id == _TYPE_INDEXED_SPLIT:
+ imagic = r.i64()
+ if imagic != _INDEXED_SPLIT_MAGIC:
+ raise ValueError("bad IndexedSplit magic %d" % imagic)
+ iversion = r.i32()
+ if iversion != _INDEXED_SPLIT_VERSION:
+ raise ValueError(
+ "unsupported IndexedSplit version %d (expected %d)" %
(iversion, _INDEXED_SPLIT_VERSION))
+ data_split = _read_datasplit_body(r, partition_fields, key_fields)
+ # row_ranges select which global row ids to read -- must be preserved,
+ # else the reader scans the whole file instead of the ANN/row-id
result.
+ row_ranges = [Range(r.i64(), r.i64()) for _ in range(r.i32())]
+ return IndexedSplit(data_split, row_ranges, _read_scores(r))
+ raise ValueError("unsupported split type id %d" % type_id)
+
+
+def _read_scores(r: '_Reader') -> Optional[List[float]]:
+ if r.u8() == 0:
+ return None
+ return [r.f32() for _ in range(r.i32())]
+
+
+def _read_datasplit_body(r: _Reader, partition_fields: List[DataField],
+ key_fields: Optional[List[DataField]] = None) ->
DataSplit:
+ if r.i64() != _SPLIT_MAGIC:
+ raise ValueError("bad DataSplit magic")
+ version = r.i32()
+ if version != _SPLIT_VERSION:
+ raise ValueError(
+ "unsupported DataSplit version %d (expected %d)" % (version,
_SPLIT_VERSION))
+ snapshot_id = r.i64() # scanned snapshot; row-id conflict detection
needs it
+ partition = GenericRowDeserializer.from_bytes(r.take(r.i32()),
partition_fields)
+ bucket = r.i32()
+ bucket_path = r.java_utf()
+ if r.u8() == 1:
+ r.i32() # total_buckets
+ if r.i32() != 0: # deprecated beforeFiles; Java
rejects non-empty
+ raise ValueError("cannot deserialize a split with before files")
+ if r.u8() != 0: # beforeDeletionFiles must be null
+ raise ValueError("cannot deserialize a split with before deletion
files")
+ file_count = r.i32()
+ files = [_datafilemeta_from_row(r.take(r.i32()), bucket_path, key_fields)
+ for _ in range(file_count)]
+ data_deletion_files = _read_deletion_list(r)
+ r.u8() # isStreaming
+ raw_convertible = r.u8() != 0
+ return DataSplit(
+ files=files,
+ partition=partition,
+ bucket=bucket,
+ raw_convertible=raw_convertible,
+ data_deletion_files=data_deletion_files,
+ snapshot_id=snapshot_id,
+ )
+
+
+def _decode_key(b: Optional[bytes], key_fields: Optional[List[DataField]]) ->
GenericRow:
+ """Decode a min/max key (serialized BinaryRow); empty for append tables."""
+ if not key_fields or not b:
+ return GenericRow([], [])
+ return GenericRowDeserializer.from_bytes(b, key_fields)
+
+
+def _datafilemeta_from_row(row_bytes: bytes, bucket_path: str,
+ key_fields: Optional[List[DataField]] = None) ->
DataFileMeta:
+ row = BinaryRow(struct.pack('>i', _DFM_ARITY) + row_bytes, _DFM_FIELDS)
+ g = row.get_field
+ file_name = g(0)
+ external_path = g(17)
+ ct = g(12)
+ ct = Timestamp(int(ct)) if ct is not None else None
+ # min/max keys drive PK merge-on-read; stats unused, left empty.
+ meta = DataFileMeta(
+ file_name=file_name,
+ file_size=g(1),
+ row_count=g(2),
+ min_key=_decode_key(g(3), key_fields),
+ max_key=_decode_key(g(4), key_fields),
+ key_stats=SimpleStats.empty_stats(),
+ value_stats=SimpleStats.empty_stats(),
+ min_sequence_number=g(7),
+ max_sequence_number=g(8),
+ schema_id=g(9),
+ level=g(10),
+ extra_files=_decode_str_array(g(11)) or [],
+ creation_time=ct,
+ delete_row_count=g(13),
+ embedded_index=g(14),
+ file_source=g(15),
+ value_stats_cols=_decode_str_array(g(16)),
+ external_path=external_path,
+ first_row_id=g(18),
+ write_cols=_decode_str_array(g(19)),
+ )
+ meta.file_path = external_path if external_path else "%s/%s" %
(bucket_path.rstrip('/'), file_name)
+ return meta
+
+
+def _read_deletion_list(r: _Reader) -> Optional[List[Optional[DeletionFile]]]:
+ if r.u8() == 0:
+ return None
+ result: List[Optional[DeletionFile]] = []
+ for _ in range(r.i32()):
+ if r.u8() == 0:
+ result.append(None)
+ continue
+ path = r.java_utf()
+ offset, length, cardinality = r.i64(), r.i64(), r.i64()
+ result.append(DeletionFile(
+ dv_index_path=path,
+ offset=offset,
+ length=length,
+ cardinality=None if cardinality == -1 else cardinality,
+ ))
+ return result
diff --git a/paimon-python/pypaimon/read/table_scan.py
b/paimon-python/pypaimon/read/table_scan.py
index 8c89fc3b76..313046aecb 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -16,9 +16,11 @@
# under the License.
import json as _json
+import logging
from typing import Optional, Tuple
from pypaimon.catalog.catalog_exception import TableNoPermissionException
+from pypaimon.common.identifier import UNKNOWN_DATABASE
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
@@ -28,6 +30,15 @@ from pypaimon.read.query_auth_split import
resolve_auth_result, wrap_plan_with_a
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.scanner.file_scanner import FileScanner
+logger = logging.getLogger(__name__)
+
+# Options native forwards to Rust; any other copy() override is invisible to
Rust.
+_NATIVE_FORWARDED_OPTIONS = frozenset({
+ CoreOptions.SCAN_NATIVE_PLAN_ENABLED.key(),
+ CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(),
+ CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(),
+})
+
class TableScan:
"""Implementation of TableScan for native Python reading."""
@@ -52,11 +63,134 @@ class TableScan:
def plan(self) -> Plan:
auth_result = self.__auth_query()
+ # Native planning covers only a plain full-snapshot scan and bypasses
the
+ # auth-aware file scanner; fall back to the normal path otherwise.
+ if (auth_result is None and self.table.options.native_plan_enabled()
+ and self._native_plan_supported()):
+ native = self._try_native_plan()
+ if native is not None:
+ return native
if auth_result is not None:
prune_scanner_by_auth(self.table, self.file_scanner, auth_result)
plan = self.file_scanner.scan()
return wrap_plan_with_auth(auth_result, plan)
+ def _native_plan_supported(self) -> bool:
+ # Any probe failure (e.g. a remote schema/metadata read) must fall
back, not fail the scan.
+ try:
+ return self._native_plan_supported_impl()
+ except Exception as e:
+ logger.warning("Native-plan capability probe failed, falling back:
%s", e)
+ return False
+
+ def _native_plan_supported_impl(self) -> bool:
+ """Fall back to the Python scanner for scans native can't carry:
+ shard/slice, chunk-shuffle, global-index, first-row merge-engine (Rust
+ drops L0), deletion vectors (Python drops L0), data evolution
+ (dedicated split generator), postpone bucket (drops synthetic buckets),
+ a primary-key table whose trimmed PK is empty (PK equals the partition
+ key; native may mark splits raw-convertible and skip merge), dynamic
+ bucket / cross-partition PK tables (unconfirmed Rust parity), any
+ partitioned table (Rust bucket_path vs the writer's str(value) can
+ diverge), a stale schema (Rust reloads the latest), copy() overrides
+ Rust does not see (e.g. a removed scan.snapshot-id), a row limit
+ (native has no plan-time limit pushdown), query auth, non-main branch,
+ time-travel, scan.version, incremental, a missing/old pypaimon-rust, or
+ a catalog / identifier Rust cannot reconstruct. Keep this capability
+ gate in sync when adding scan features."""
+ from pypaimon.read.native_plan import native_runtime_available
+ if not native_runtime_available():
+ return False
+ # Native has no plan-time limit pushdown; Python trims splits before
read.
+ if self.limit is not None:
+ return False
+ fs = self.file_scanner
+ if (getattr(fs, 'idx_of_this_subtask', None) is not None
+ or getattr(fs, 'start_pos_of_this_subtask', None) is not None
+ or getattr(fs, 'chunk_shuffle', None) is not None
+ or getattr(fs, '_global_index_result', None) is not None
+ or getattr(fs, 'deletion_vectors_enabled', False)
+ or getattr(fs, 'data_evolution', False)
+ or getattr(fs, 'only_read_real_buckets', False)):
+ return False
+ loader = getattr(
+ getattr(self.table, 'catalog_environment', None),
+ 'catalog_loader',
+ None,
+ )
+ context_fn = getattr(loader, 'context', None)
+ if not callable(context_fn):
+ return False
+ from pypaimon.read.native_plan import _catalog_metastore
+ if _catalog_metastore(loader) is None:
+ return False
+ context = context_fn()
+ catalog_options = getattr(context, 'options', None)
+ if catalog_options is None:
+ return False
+ if any(getattr(context, attr, None) is not None for attr in (
+ 'hadoop_conf', 'prefer_io_loader', 'fallback_io_loader')):
+ return False
+ database_name = self.table.identifier.get_database_name()
+ if not database_name or database_name == UNKNOWN_DATABASE or '.' in
database_name:
+ return False
+ if self.table.options.query_auth_enabled \
+ or self.table.options.merge_engine() == 'first-row' \
+ or self.table.current_branch() != 'main':
+ return False
+ # Empty trimmed PK (PK == partition key): native skips merge ->
duplicate/stale rows.
+ if getattr(self.table, 'is_primary_key_table', False) \
+ and not self.table.trimmed_primary_keys:
+ return False
+ # Dynamic-bucket / cross-partition PK: Rust parity unconfirmed -> fall
back.
+ from pypaimon.table.bucket_mode import BucketMode
+ if self.table.bucket_mode() in (BucketMode.HASH_DYNAMIC,
BucketMode.CROSS_PARTITION):
+ return False
+ # Rust bucket_path vs the writer's unescaped str(value) can diverge ->
fall back.
+ if self.table.partition_keys:
+ return False
+ # Rust reloads the latest schema; fall back if this table's schema is
stale.
+ latest_schema = self.table.schema_manager.latest()
+ if latest_schema is not None and latest_schema.id !=
self.table.table_schema.id:
+ return False
+ # copy() overrides Rust can't see (e.g. removed scan.snapshot-id) ->
fall back.
+ overrides = set(getattr(self.table, '_applied_dynamic_options', {}) or
{})
+ if overrides - _NATIVE_FORWARDED_OPTIONS:
+ return False
+ from pypaimon.snapshot.time_travel_util import SCAN_KEYS
+ options = self.table.options.options
+ if any(options.contains_key(k) for k in SCAN_KEYS) \
+ or options.contains_key('scan.version'):
+ return False
+ return not options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
+
+ def _try_native_plan(self) -> Optional[Plan]:
+ """Plan via pypaimon_rust, then drop partitions the predicate rejects.
+
+ The predicate is not pushed to the native planner, so this may read
more
+ files; the reader's row filter/limit still apply, so results match.
Return
+ None when Rust finds no splits so the caller can use the matching
Python
+ fallback (with scan stats when requested).
+ """
+ from pypaimon.read.native_plan import native_plan
+
+ try:
+ splits = native_plan(self.table)
+ if not splits:
+ return None
+ snapshot_id = splits[0].snapshot_id
+ partition_predicate = self.file_scanner.partition_key_predicate
+ if partition_predicate is not None:
+ splits = [s for s in splits
+ if getattr(s, 'partition', None) is None
+ or partition_predicate.test(s.partition)]
+ return Plan(splits, snapshot_id=snapshot_id)
+ except Exception as e:
+ # Any native construction/planning/pruning failure -> fall back.
+ logger.warning(
+ "Native plan failed, falling back to the Python scanner: %s",
e)
+ return None
+
def plan_for_write(self) -> Plan:
if self.__auth_query() is not None:
raise TableNoPermissionException(self.table.identifier)
@@ -65,13 +199,20 @@ class TableScan:
def __auth_query(self):
return resolve_auth_result(self._query_auth_fn, self._read_type)
- def scan_with_stats(self) -> Tuple[Plan, ScanStats]:
+ def scan_with_stats(self) -> Tuple[Plan, Optional[ScanStats]]:
"""Run :meth:`plan` while recording manifest / pruning counters.
Only used by :meth:`ReadBuilder.explain`; the regular read path
- keeps going through :meth:`plan`.
+ keeps going through :meth:`plan`. Native planning is not tracked, so
+ stats is None on the native path -- explain reflects the real plan and
+ marks the pruning funnel as untracked.
"""
auth_result = self.__auth_query()
+ if (auth_result is None and self.table.options.native_plan_enabled()
+ and self._native_plan_supported()):
+ native = self._try_native_plan()
+ if native is not None:
+ return native, None
if auth_result is not None:
prune_scanner_by_auth(self.table, self.file_scanner, auth_result)
plan, stats = self.file_scanner.scan_with_stats()
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 56e2b9518c..8edbc14021 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -520,8 +520,12 @@ class FileStoreTable(Table):
)
catalog_env = self.catalog_environment.copy(new_identifier)
- return FileStoreTable(self.file_io, new_identifier, self.table_path,
new_table_schema,
- catalog_env)
+ new_table = FileStoreTable(self.file_io, new_identifier,
self.table_path,
+ new_table_schema, catalog_env)
+ # Cumulative copy() overrides (removals kept as None) vs the on-disk
schema.
+ new_table._applied_dynamic_options = {
+ **getattr(self, '_applied_dynamic_options', {}), **options}
+ return new_table
def _try_time_travel(self, options: Options) -> Optional[TableSchema]:
"""
diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py
b/paimon-python/pypaimon/tests/native_plan_integration_test.py
new file mode 100644
index 0000000000..a78e1b55a0
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py
@@ -0,0 +1,230 @@
+# 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 tempfile
+import unittest
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+
+
+def _has_native_planner():
+ try:
+ from pypaimon_rust.datafusion import PaimonCatalog, Split
+ except Exception:
+ return False
+ return hasattr(PaimonCatalog, 'get_table') and hasattr(Split, 'serialize')
+
+
[email protected](_has_native_planner(),
+ "pypaimon_rust with split-planning API not installed")
+class NativePlanIntegrationTest(unittest.TestCase):
+ """Live round-trip guarding the cross-language SplitSerializer against
drift:
+ plan via pypaimon_rust, decode, and require the same rows as the normal
plan.
+ The golden unit tests only prove self-consistency; this proves byte-compat
+ with the real producer."""
+
+ def setUp(self):
+ self.cat = CatalogFactory.create({'warehouse':
tempfile.mkdtemp(prefix='np_it_')})
+ self.cat.create_database('default', True)
+ self.schema = pa.schema([('k', pa.int64()), ('v', pa.string())])
+
+ def _write(self, name, rows):
+ t = self.cat.get_table('default.%s' % name)
+ wb = t.new_batch_write_builder()
+ w, c = wb.new_write(), wb.new_commit()
+ w.write_arrow(pa.Table.from_pylist(rows, schema=self.schema))
+ c.commit(w.prepare_commit())
+ w.close()
+ c.close()
+
+ def _plan_and_read(self, name, native):
+ t = self.cat.get_table('default.%s' % name)
+ if native:
+ t = t.copy({'scan.native-plan.enabled': 'true'})
+ rb = t.new_read_builder()
+ plan = rb.new_scan().plan()
+ rows = rb.new_read().to_arrow(plan.splits()).to_pylist()
+ return plan.snapshot_id, sorted(rows, key=lambda r: r['k'])
+
+ def _assert_matches(self, name, expect_native=True):
+ sid_n, rows_n = self._plan_and_read(name, native=False)
+ sid_r, rows_r = self._plan_and_read(name, native=True)
+ self.assertEqual(rows_r, rows_n)
+ self.assertEqual(sid_r, sid_n) # snapshot id preserved through
native plan
+ self.assertIsNotNone(sid_r)
+ # Guard against a false green where native silently fell back to
Python: assert the
+ # native planner was (or was not) actually used, as expected for this
table.
+ native_table = self.cat.get_table('default.%s' % name).copy(
+ {'scan.native-plan.enabled': 'true'})
+ self.assertEqual(
+ native_table.new_read_builder().explain().native_planned,
expect_native)
+
+ def test_primary_key_matches_normal_plan(self):
+ self.cat.create_table('default.pk_t', Schema.from_pyarrow_schema(
+ self.schema, primary_keys=['k'], options={'bucket': '1'}), False)
+ self._write('pk_t', [{'k': 1, 'v': 'a1'}, {'k': 2, 'v': 'b1'}])
+ self._write('pk_t', [{'k': 2, 'v': 'b2'}, {'k': 3, 'v': 'c1'}]) # k=2
updated
+ self._assert_matches('pk_t')
+
+ def test_pk_equal_to_partition_key_falls_back(self):
+ # Empty trimmed PK: native would skip merge and return duplicates ->
must fall back.
+ self.cat.create_table('default.pkpart_t', Schema.from_pyarrow_schema(
+ self.schema, partition_keys=['k'], primary_keys=['k'],
options={'bucket': '1'}), False)
+ self._write('pkpart_t', [{'k': 1, 'v': 'a1'}, {'k': 2, 'v': 'b1'}])
+ self._write('pkpart_t', [{'k': 2, 'v': 'b2'}])
+ native_table = self.cat.get_table('default.pkpart_t').copy(
+ {'scan.native-plan.enabled': 'true'})
+
self.assertFalse(native_table.new_read_builder().explain().native_planned)
+ with self.assertRaises(ValueError):
+ rb = native_table.new_read_builder()
+ rb.new_read().to_arrow(rb.new_scan().plan().splits())
+
+ def test_copy_removed_persisted_scan_option_falls_back(self):
+ # copy() removes a persisted scan.snapshot-id that Rust would still
reload -> fall back.
+ self.cat.create_table('default.snapopt_t', Schema.from_pyarrow_schema(
+ self.schema, options={'scan.snapshot-id': '1'}), False)
+ self._write('snapopt_t', [{'k': 1, 'v': 'a'}]) # snapshot 1
+ self._write('snapopt_t', [{'k': 2, 'v': 'b'}]) # snapshot 2
+ native = self.cat.get_table('default.snapopt_t').copy(
+ {'scan.snapshot-id': None, 'scan.native-plan.enabled': 'true'})
+ self.assertFalse(native.new_read_builder().explain().native_planned)
+
+ def test_first_row_merge_engine_falls_back(self):
+ self.cat.create_table('default.fr_t', Schema.from_pyarrow_schema(
+ self.schema, primary_keys=['k'],
+ options={'bucket': '1', 'merge-engine': 'first-row'}), False)
+ self._write('fr_t', [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}])
+ self._write('fr_t', [{'k': 1, 'v': 'X'}, {'k': 3, 'v': 'c'}]) # k=1
stays 'a'
+ self._assert_matches('fr_t', expect_native=False)
+
+ def test_append_matches_normal_plan(self):
+ self.cat.create_table(
+ 'default.ap_t', Schema.from_pyarrow_schema(self.schema), False)
+ self._write('ap_t', [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}])
+ self._write('ap_t', [{'k': 3, 'v': 'c'}])
+ self._assert_matches('ap_t')
+
+ def test_dynamic_split_target_size_matches_normal_plan(self):
+ self.cat.create_table(
+ 'default.split_t', Schema.from_pyarrow_schema(self.schema), False)
+ self._write('split_t', [{'k': 1, 'v': 'a'}])
+ self._write('split_t', [{'k': 2, 'v': 'b'}])
+ options = {'source.split.target-size': '1b'}
+ normal_table = self.cat.get_table('default.split_t').copy(options)
+ native_table = normal_table.copy({'scan.native-plan.enabled': 'true'})
+
+ normal = normal_table.new_read_builder().new_scan().plan()
+ native = native_table.new_read_builder().explain()
+
+ self.assertTrue(native.native_planned)
+ self.assertEqual(native.split_count, len(normal.splits()))
+ self.assertGreater(native.split_count, 1)
+
+ def test_dynamic_split_open_file_cost_matches_normal_plan(self):
+ stored_options = {
+ 'source.split.target-size': '128mb',
+ 'source.split.open-file-cost': '1b',
+ }
+ self.cat.create_table('default.open_cost_t',
Schema.from_pyarrow_schema(
+ self.schema, options=stored_options), False)
+ self._write('open_cost_t', [{'k': 1, 'v': 'a'}])
+ self._write('open_cost_t', [{'k': 2, 'v': 'b'}])
+ self._write('open_cost_t', [{'k': 3, 'v': 'c'}])
+ base_table = self.cat.get_table('default.open_cost_t')
+ normal_table = base_table.copy({'source.split.open-file-cost': '64mb'})
+ native_table = normal_table.copy({'scan.native-plan.enabled': 'true'})
+
+ baseline = base_table.new_read_builder().new_scan().plan()
+ normal = normal_table.new_read_builder().new_scan().plan()
+ native = native_table.new_read_builder().explain()
+
+ self.assertEqual(len(baseline.splits()), 1)
+ self.assertGreater(len(normal.splits()), len(baseline.splits()))
+ self.assertTrue(native.native_planned)
+ self.assertEqual(native.split_count, len(normal.splits()))
+
+ def test_dynamic_split_option_reset_matches_normal_plan(self):
+ stored_options = {
+ 'source.split.target-size': '1b',
+ 'source.split.open-file-cost': '1b',
+ }
+ self.cat.create_table('default.split_reset_t',
Schema.from_pyarrow_schema(
+ self.schema, options=stored_options), False)
+ self._write('split_reset_t', [{'k': 1, 'v': 'a'}])
+ self._write('split_reset_t', [{'k': 2, 'v': 'b'}])
+ reset_options = {
+ 'source.split.target-size': None,
+ 'source.split.open-file-cost': None,
+ }
+ normal_table =
self.cat.get_table('default.split_reset_t').copy(reset_options)
+ native_table = normal_table.copy({'scan.native-plan.enabled': 'true'})
+
+ normal = normal_table.new_read_builder().new_scan().plan()
+ native = native_table.new_read_builder().explain()
+
+ self.assertTrue(native.native_planned)
+ self.assertEqual(native.split_count, len(normal.splits()))
+ self.assertEqual(native.split_count, 1)
+
+ def test_partitioned_table_falls_back(self):
+ # Rust bucket_path can diverge from the writer's str(value) partition
dir -> fall back.
+ schema = pa.schema([('k', pa.int64()), ('p', pa.string())])
+ self.cat.create_table('default.pt_t', Schema.from_pyarrow_schema(
+ schema, partition_keys=['p']), False)
+ t = self.cat.get_table('default.pt_t')
+ wb = t.new_batch_write_builder()
+ w, c = wb.new_write(), wb.new_commit()
+ w.write_arrow(pa.Table.from_pylist(
+ [{'k': 1, 'p': 'a'}, {'k': 2, 'p': 'a'}, {'k': 3, 'p': 'b'}],
schema=schema))
+ c.commit(w.prepare_commit())
+ w.close()
+ c.close()
+
+ native_table = self.cat.get_table('default.pt_t').copy(
+ {'scan.native-plan.enabled': 'true'})
+
self.assertFalse(native_table.new_read_builder().explain().native_planned)
+
+ def test_explain_reflects_native_plan(self):
+ self.cat.create_table(
+ 'default.ex_t', Schema.from_pyarrow_schema(self.schema), False)
+ self._write('ex_t', [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}])
+ normal =
self.cat.get_table('default.ex_t').new_read_builder().explain()
+ native = self.cat.get_table('default.ex_t').copy(
+ {'scan.native-plan.enabled': 'true'}).new_read_builder().explain()
+ self.assertFalse(normal.native_planned)
+ self.assertTrue(native.native_planned)
+ self.assertEqual(native.split_count, normal.split_count)
+ self.assertEqual(native.snapshot_id, normal.snapshot_id)
+ self.assertIn('native', str(native)) # render shows the Planner line
+
+ def test_empty_table_explain_reflects_python_fallback(self):
+ self.cat.create_table(
+ 'default.empty_t', Schema.from_pyarrow_schema(self.schema), False)
+ normal =
self.cat.get_table('default.empty_t').new_read_builder().explain()
+ fallback = self.cat.get_table('default.empty_t').copy(
+ {'scan.native-plan.enabled': 'true'}).new_read_builder().explain()
+
+ self.assertFalse(fallback.native_planned)
+ self.assertEqual(fallback.snapshot_id, normal.snapshot_id)
+ self.assertEqual(fallback.split_count, 0)
+ self.assertNotIn('Planner:', str(fallback))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
new file mode 100644
index 0000000000..b21ac808a6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -0,0 +1,379 @@
+# 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 sys
+import unittest
+from types import ModuleType
+from unittest.mock import Mock, patch
+
+from pypaimon.catalog.catalog_context import CatalogContext
+from pypaimon.catalog.filesystem_catalog_loader import FileSystemCatalogLoader
+from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader
+from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
+from pypaimon.read.native_plan import _catalog_options, native_plan
+from pypaimon.read.scan_stats import ScanStats
+from pypaimon.read.table_scan import TableScan
+from pypaimon.table.bucket_mode import BucketMode
+
+
+def _scan(native_enabled, file_scanner):
+ """Build a TableScan without running its heavy __init__."""
+ scan = TableScan.__new__(TableScan)
+ scan.table = Mock()
+ scan.table.options.native_plan_enabled.return_value = native_enabled
+ scan.table.options.options.contains_key.return_value = False # no
time-travel
+ scan.table.options.options.contains.return_value = False # no
incremental
+ scan.table.options.merge_engine.return_value = None # not
first-row
+ scan.table.options.query_auth_enabled = False
+ scan.table.current_branch.return_value = 'main'
+ scan.table.is_primary_key_table = False # not a pk table
+ scan.table.trimmed_primary_keys = ['k'] # non-empty trimmed pk
+ scan.table.bucket_mode.return_value = BucketMode.HASH_FIXED
+ scan.table._applied_dynamic_options = {} # no copy() overrides
+ scan.table.partition_keys = [] # not partitioned
+ scan.table.table_schema.id = 1
+ scan.table.schema_manager.latest.return_value.id = 1 # loaded schema is
latest
+ scan.table.identifier.get_database_name.return_value = 'default'
+ scan.table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
+ CatalogContext.create_from_options(Options({}))) #
filesystem catalog
+ file_scanner.idx_of_this_subtask = None # no shard
+ file_scanner.start_pos_of_this_subtask = None # no slice
+ file_scanner.chunk_shuffle = None # no chunk-shuffle
+ file_scanner._global_index_result = None # no global-index result
+ file_scanner.deletion_vectors_enabled = False # no deletion vectors
+ file_scanner.data_evolution = False # no data evolution
+ file_scanner.only_read_real_buckets = False # not postpone bucket
+ scan.file_scanner = file_scanner
+ scan._query_auth_fn = None # no query-auth restrictions
+ scan._read_type = None
+ scan.limit = None # no row limit
+ return scan
+
+
+class NativePlanTest(unittest.TestCase):
+
+ def setUp(self):
+ # Make the real capability probe see a split-API-capable pypaimon-rust
so
+ # gate tests route natively. Tests that call native_plan() directly
override
+ # sys.modules within their own block.
+ fake_df = ModuleType('pypaimon_rust.datafusion')
+ fake_df.PaimonCatalog = type(
+ 'PaimonCatalog', (), {'get_table': lambda self, name: None})
+ fake_df.Split = type('Split', (), {'serialize': lambda self: b''})
+ fake_mod = ModuleType('pypaimon_rust')
+ fake_mod.datafusion = fake_df
+ patcher = patch.dict(
+ sys.modules,
+ {'pypaimon_rust': fake_mod, 'pypaimon_rust.datafusion': fake_df})
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_switch_defaults_off(self):
+ self.assertFalse(CoreOptions(Options({})).native_plan_enabled())
+ self.assertTrue(
+ CoreOptions(Options({"scan.native-plan.enabled":
"true"})).native_plan_enabled())
+
+ def test_plan_uses_file_scanner_when_switch_off(self):
+ fs = Mock()
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=False, file_scanner=fs)
+ self.assertIs(scan.plan(), sentinel)
+ fs.scan.assert_called_once_with()
+
+ def test_plan_routes_to_native_and_prunes_partitions(self):
+ # Native planner returns every partition; the predicate keeps only
[2026, 7].
+ keep = Mock(partition=Mock(values=[2026, 7]))
+ drop = Mock(partition=Mock(values=[2025, 1]))
+ pred = Mock()
+ pred.test.side_effect = lambda part: part.values == [2026, 7]
+ fs = Mock(partition_key_predicate=pred)
+ scan = _scan(native_enabled=True, file_scanner=fs)
+
+ with patch('pypaimon.read.native_plan.native_plan',
return_value=[keep, drop]) as np:
+ plan = scan.plan()
+
+ np.assert_called_once_with(scan.table)
+ fs.scan.assert_not_called()
+ self.assertEqual(plan.splits(), [keep])
+
+ def test_plan_falls_back_when_partition_prune_raises(self):
+ pred = Mock()
+ pred.test.side_effect = RuntimeError('predicate boom')
+ fs = Mock(partition_key_predicate=pred)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ split = Mock(partition=Mock(values=[2026, 7]), snapshot_id=1)
+ with patch('pypaimon.read.native_plan.native_plan',
return_value=[split]):
+ self.assertIs(scan.plan(), sentinel)
+ fs.scan.assert_called_once_with()
+
+ def test_plan_native_no_partition_predicate_keeps_all(self):
+ splits = [Mock(partition=Mock(values=[1])),
Mock(partition=Mock(values=[2]))]
+ fs = Mock(partition_key_predicate=None)
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ with patch('pypaimon.read.native_plan.native_plan',
return_value=splits):
+ self.assertEqual(scan.plan().splits(), splits)
+
+ def test_plan_falls_back_when_scan_is_not_plain(self):
+ # Native planning does not carry shard/slice, global-index, or
+ # time-travel/incremental scans -> must fall back to the file scanner.
+ def check(setup):
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ setup(scan, fs)
+ with patch('pypaimon.read.native_plan.native_plan') as np:
+ self.assertIs(scan.plan(), sentinel)
+ np.assert_not_called()
+ fs.scan.assert_called_once_with()
+
+ check(lambda s, fs: setattr(fs, 'idx_of_this_subtask', 0))
+ check(lambda s, fs: setattr(fs, 'start_pos_of_this_subtask', 0))
+ check(lambda s, fs: setattr(fs, 'chunk_shuffle', (1, 100)))
+ check(lambda s, fs: setattr(fs, '_global_index_result', object()))
+ check(lambda s, fs: setattr(fs, 'deletion_vectors_enabled', True))
+ check(lambda s, fs: setattr(fs, 'data_evolution', True))
+ check(lambda s, fs: setattr(fs, 'only_read_real_buckets', True))
+ check(lambda s, fs: (setattr(s.table, 'is_primary_key_table', True),
+ setattr(s.table, 'trimmed_primary_keys', [])))
+ check(lambda s, fs: s.table.bucket_mode.__setattr__(
+ 'return_value', BucketMode.HASH_DYNAMIC))
+ check(lambda s, fs: s.table.bucket_mode.__setattr__(
+ 'return_value', BucketMode.CROSS_PARTITION))
+ check(lambda s, fs: setattr(
+ s.table, '_applied_dynamic_options', {'scan.snapshot-id': None}))
+ check(lambda s, fs: setattr(s.table, 'partition_keys', ['dt']))
+ check(lambda s, fs:
setattr(s.table.schema_manager.latest.return_value, 'id', 2))
+ check(lambda s, fs: s.table.schema_manager.latest.__setattr__(
+ 'side_effect', RuntimeError('metadata read failed')))
+ check(lambda s, fs: setattr(s, 'limit', 5))
+ check(lambda s, fs: s.table.options.options.contains_key.__setattr__(
+ 'side_effect', lambda k: k == 'scan.version'))
+ check(lambda s, fs: s.table.options.merge_engine.__setattr__(
+ 'return_value', 'first-row'))
+ check(lambda s, fs: setattr(s.table.options, 'query_auth_enabled',
True))
+ check(lambda s, fs: s.table.current_branch.__setattr__('return_value',
'b1'))
+ check(lambda s, fs: s.table.identifier.get_database_name.__setattr__(
+ 'return_value', 'db.name'))
+ check(lambda s, fs: s.table.identifier.get_database_name.__setattr__(
+ 'return_value', 'unknown'))
+ check(lambda s, fs: setattr(
+ s.table.catalog_environment, 'catalog_loader', object())) # no
context()
+ for attr in ('hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'):
+ check(lambda s, fs, attr=attr: setattr(
+ s.table.catalog_environment.catalog_loader.context(), attr,
object()))
+ check(lambda s, fs: s.table.options.options.contains_key.__setattr__(
+ 'return_value', True)) # time-travel
+ check(lambda s, fs: s.table.options.options.contains.__setattr__(
+ 'return_value', True)) # incremental
+
+ def test_plan_native_empty_falls_back(self):
+ # Empty native result -> fall back for an atomic snapshot id.
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ with patch('pypaimon.read.native_plan.native_plan', return_value=[]):
+ self.assertIs(scan.plan(), sentinel)
+ fs.scan.assert_called_once_with()
+
+ def test_plan_falls_back_when_rust_unavailable(self):
+ # scan.native-plan.enabled but pypaimon-rust missing/old -> fall back,
+ # not crash.
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ with patch('pypaimon.read.native_plan.native_runtime_available',
+ return_value=False), \
+ patch('pypaimon.read.native_plan.native_plan') as np:
+ self.assertIs(scan.plan(), sentinel)
+ np.assert_not_called()
+ fs.scan.assert_called_once_with()
+
+ def test_plan_falls_back_when_native_plan_raises(self):
+ # A native planning failure (e.g. unsupported scheme) must fall back,
not crash.
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ with patch('pypaimon.read.native_plan.native_plan',
+ side_effect=RuntimeError('unsupported scheme viewfs://')):
+ self.assertIs(scan.plan(), sentinel)
+ fs.scan.assert_called_once_with()
+
+ def test_plan_falls_back_for_jdbc_catalog_loader(self):
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ scan.table.catalog_environment.catalog_loader = JdbcCatalogLoader(
+ CatalogContext.create_from_options(Options({})))
+
+ with patch('pypaimon.read.native_plan.native_plan') as np:
+ self.assertIs(scan.plan(), sentinel)
+
+ np.assert_not_called()
+ fs.scan.assert_called_once_with()
+
+ def test_plan_falls_back_for_builtin_catalog_loader_subclasses(self):
+ class RoutedFileSystemLoader(FileSystemCatalogLoader):
+ def load(self):
+ return object()
+
+ class RoutedRESTLoader(RESTCatalogLoader):
+ def load(self):
+ return object()
+
+ for loader_class in (RoutedFileSystemLoader, RoutedRESTLoader):
+ with self.subTest(loader_class=loader_class.__name__):
+ fs = Mock(partition_key_predicate=None)
+ sentinel = object()
+ fs.scan.return_value = sentinel
+ scan = _scan(native_enabled=True, file_scanner=fs)
+ scan.table.catalog_environment.catalog_loader = loader_class(
+ CatalogContext.create_from_options(Options({})))
+
+ with patch('pypaimon.read.native_plan.native_plan') as np:
+ self.assertIs(scan.plan(), sentinel)
+
+ np.assert_not_called()
+ fs.scan.assert_called_once_with()
+
+ def test_scan_with_stats_native_empty_uses_fallback_stats(self):
+ fs = Mock(partition_key_predicate=None)
+ fallback_plan = object()
+ fallback_stats = ScanStats(manifest_files_total=7)
+ fs.scan_with_stats.return_value = (fallback_plan, fallback_stats)
+ scan = _scan(native_enabled=True, file_scanner=fs)
+
+ with patch('pypaimon.read.native_plan.native_plan', return_value=[])
as np:
+ plan, stats = scan.scan_with_stats()
+
+ self.assertIs(plan, fallback_plan)
+ self.assertIs(stats, fallback_stats)
+ np.assert_called_once_with(scan.table)
+ fs.scan_with_stats.assert_called_once_with()
+ fs.scan.assert_not_called()
+
+ def test_catalog_options_are_normalized_for_rust(self):
+ table = Mock()
+ table.catalog_environment.catalog_loader = FileSystemCatalogLoader(
+ CatalogContext.create_from_options(Options({
+ 'warehouse': '/tmp/warehouse',
+ 'data-token.enabled': True,
+ 'retry-count': 3,
+ 'unset': None,
+ })))
+
+ self.assertEqual(_catalog_options(table), {
+ 'warehouse': '/tmp/warehouse',
+ # Lowercase so Rust's case-sensitive bool parser accepts it.
+ 'data-token.enabled': 'true',
+ 'retry-count': '3',
+ 'metastore': 'filesystem',
+ })
+
+ def test_catalog_options_use_actual_rest_loader_type(self):
+ table = Mock()
+ table.catalog_environment.catalog_loader = RESTCatalogLoader(
+ CatalogContext.create_from_options(Options({
+ 'uri': 'http://localhost:8181',
+ })))
+
+ self.assertEqual(_catalog_options(table), {
+ 'uri': 'http://localhost:8181',
+ 'metastore': 'rest',
+ })
+
+ def test_catalog_options_reject_loader_subclass(self):
+ class RoutedFileSystemLoader(FileSystemCatalogLoader):
+ pass
+
+ table = Mock()
+ table.catalog_environment.catalog_loader = RoutedFileSystemLoader(
+ CatalogContext.create_from_options(Options({})))
+
+ with self.assertRaisesRegex(ValueError, 'exact built-in catalog
loader'):
+ _catalog_options(table)
+
+ def test_native_plan_threads_trimmed_keys_to_deserializer(self):
+ # PK tables route through: the trimmed primary keys must reach the
+ # deserializer so per-file min/max keys are decoded for merge-on-read.
+ kfields = [object()]
+ table = Mock(trimmed_primary_keys_fields=kfields)
+ table.table_schema = Mock(fields=[], partition_keys=[])
+ table.options.source_split_target_size.return_value = 1024
+ table.options.source_split_open_file_cost.return_value = 128
+ split = Mock()
+ split.serialize.return_value = b'bytes'
+ rt = Mock()
+
rt.new_read_builder.return_value.new_scan.return_value.plan.return_value \
+ .splits.return_value = [split]
+ catalog = Mock()
+ catalog.get_table.return_value = rt
+
+ fake_df = ModuleType('pypaimon_rust.datafusion')
+ fake_df.PaimonCatalog = Mock(return_value=catalog)
+ fake_df.Split = type('Split', (), {'serialize': lambda self: b''})
+ fake_mod = ModuleType('pypaimon_rust')
+ fake_mod.datafusion = fake_df
+
+ with patch.dict(sys.modules,
+ {'pypaimon_rust': fake_mod,
'pypaimon_rust.datafusion': fake_df}), \
+ patch('pypaimon.read.native_plan._catalog_options',
return_value={}), \
+ patch('pypaimon.read.native_plan.deserialize_split_v1',
+ return_value='decoded') as des:
+ result = native_plan(table)
+
+ self.assertEqual(result, ['decoded'])
+ rt.new_read_builder.assert_called_once_with({
+ CoreOptions.SOURCE_SPLIT_TARGET_SIZE.key(): '1024',
+ CoreOptions.SOURCE_SPLIT_OPEN_FILE_COST.key(): '128',
+ })
+ des.assert_called_once_with(b'bytes', [], kfields)
+
+ def test_native_plan_requires_split_api(self):
+ # An intermediate pypaimon-rust missing either get_table or
Split.serialize
+ # must raise a clear error, not an AttributeError mid-plan.
+ split_ok = type('Split', (), {'serialize': lambda self: b''})
+ catalog_ok = type('PaimonCatalog', (), {'get_table': lambda self,
name: None})
+ cases = {
+ 'no get_table': (type('PaimonCatalog', (), {}), split_ok),
+ 'no serialize': (catalog_ok, type('Split', (), {})),
+ }
+ for label, (catalog_cls, split_cls) in cases.items():
+ with self.subTest(case=label):
+ fake_df = ModuleType('pypaimon_rust.datafusion')
+ fake_df.PaimonCatalog = catalog_cls
+ fake_df.Split = split_cls
+ fake_mod = ModuleType('pypaimon_rust')
+ fake_mod.datafusion = fake_df
+ with patch.dict(
+ sys.modules,
+ {'pypaimon_rust': fake_mod,
'pypaimon_rust.datafusion': fake_df}):
+ with self.assertRaisesRegex(RuntimeError, '0.3.0'):
+ native_plan(Mock())
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/split_serializer_test.py
b/paimon-python/pypaimon/tests/split_serializer_test.py
new file mode 100644
index 0000000000..d168c6ff4b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/split_serializer_test.py
@@ -0,0 +1,170 @@
+# 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 base64
+import unittest
+
+from pypaimon.globalindex.indexed_split import IndexedSplit
+from pypaimon.read.split_serializer import (
+ _decode_modified_utf8, _decode_str_array, deserialize_split_v1)
+from pypaimon.schema.data_types import AtomicType, DataField
+
+# Hand-built BinaryArray<string> == ["id", "longcolumn12"]: hits both the
inline
+# (<=7 bytes) and var-pointer (>7) element encodings.
+_BINARY_ARRAY_STR = (
+ bytes([0x02, 0x00, 0x00, 0x00, # n = 2
+ 0x00, 0x00, 0x00, 0x00, # null bitset
+ 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x82, # "id" inline (len
2)
+ 0x0C, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00]) # ptr len=12
off=24
+ + b"longcolumn12" + bytes(4))
+
+# Golden SplitSerializer v1: DataSplit, partition [2026, 7], bucket 3,
+# bucket_path "dt=20260706/bucket-3", files file-a/file-b, dv on file-b.
+# Captured from the Java SplitSerializer (the reference wire format); live
+# pypaimon_rust byte-compat is covered by native_plan_integration_test.
+_GOLDEN_DATA_SPLIT_V1 = base64.b64decode(
+
"U1BMSVRfVjEAAAABAAAAAd7D0jAsGexmAAAACAAAAAAAAAAqAAAAHAAAAAIAAAAAAAAAAOoHAAAA"
+
"AAAABwAAAAAAAAAAAAADABRkdD0yMDI2MDcwNi9idWNrZXQtMwEAAAAIAAAAAAAAAAACAAABcAAA"
+
"QA8AAAAAZmlsZS1hAIYKAAAAAAAAAAoAAAAAAAAAFAAAAKgAAAAUAAAAwAAAAEgAAADYAAAASAAA"
+
"ACABAAAAAAAAAAAAAGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAABoAQAAZAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEA"
+
"AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAoAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAA"
+
"AAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAADAAAACAAAAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcAAAQA8AAAAAZmlsZS1iAIYKAAAAAAAAAAoAAAAA"
+
"AAAAFAAAAKgAAAAUAAAAwAAAAEgAAADYAAAASAAAACABAAAAAAAAAAAAAMgAAAAAAAAAAAAAAAAA"
+
"AAABAAAAAAAAAAgAAABoAQAAZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAsAAAAAAAAAAAAAAAAAAAEAAAAA"
+
"AAAAABQAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAAAAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAAAAAMAAAAMAAAAAgA"
+
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA"
+ "AAIAAQAJZHYvZmlsZS1iAAAAAAAAAAIAAAAAAAAACgAAAAAAAAADAAE="
+)
+
+# Same split serialized as an IndexedSplit (type id 3): identical DataSplit
body
+# with a trailing row-ranges/scores section that the reader skips.
+_GOLDEN_INDEXED_SPLIT_V1 = base64.b64decode(
+
"U1BMSVRfVjEAAAABAAAAA/L54FRCJC4xAAAAAd7D0jAsGexmAAAACAAAAAAAAAAqAAAAHAAAAAIA"
+
"AAAAAAAAAOoHAAAAAAAABwAAAAAAAAAAAAADABRkdD0yMDI2MDcwNi9idWNrZXQtMwEAAAAIAAAA"
+
"AAAAAAACAAABcAAAQA8AAAAAZmlsZS1hAIYKAAAAAAAAAAoAAAAAAAAAFAAAAKgAAAAUAAAAwAAA"
+
"AEgAAADYAAAASAAAACABAAAAAAAAAAAAAGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAABoAQAA"
+
"ZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAEAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAoAAAAAAAAAAAAAAAAA"
+
"AAAAAAAADAAAACAAAAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAAAAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcAAAQA8AAAAAZmlsZS1iAIYK"
+
"AAAAAAAAAAoAAAAAAAAAFAAAAKgAAAAUAAAAwAAAAEgAAADYAAAASAAAACABAAAAAAAAAAAAAMgA"
+
"AAAAAAAAAAAAAAAAAAABAAAAAAAAAAgAAABoAQAAZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAsAAAAAAAAA"
+
"AAAAAAAAAAEAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAAAAAMAAAAMAAAAAgAAABA"
+
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAACAA"
+
"AAAMAAAAMAAAAAgAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAAAAAAAAAAQAAAAIAAQAJZHYvZmlsZS1iAAAAAAAAAAIAAAAAAAAACgAAAAAAAAADAAEAAAAC"
+ "AAAAAAAAAAEAAAAAAAAABAAAAAAAAAALAAAAAAAAAA0BAAAAAz8AAAA+gAAAPgAAAA=="
+)
+
+
+class SplitSerializerTest(unittest.TestCase):
+
+ def _partition_fields(self):
+ return [DataField(0, 'y', AtomicType('INT')),
+ DataField(1, 'm', AtomicType('INT'))]
+
+ def test_deserialize_data_split_v1_golden(self):
+ split = deserialize_split_v1(_GOLDEN_DATA_SPLIT_V1,
self._partition_fields())
+
+ self.assertIsNotNone(split.snapshot_id)
+ self.assertEqual(split.bucket, 3)
+ self.assertEqual(list(split.partition.values), [2026, 7])
+
+ self.assertEqual([f.file_name for f in split.files], ['file-a',
'file-b'])
+ self.assertEqual(
+ [f.file_path for f in split.files],
+ ['dt=20260706/bucket-3/file-a', 'dt=20260706/bucket-3/file-b'])
+ self.assertEqual([f.level for f in split.files], [0, 1])
+ self.assertEqual(split.files[0].max_sequence_number, 100)
+ self.assertEqual(split.files[1].max_sequence_number, 200)
+
+ self.assertEqual(len(split.data_deletion_files), 2)
+ self.assertIsNone(split.data_deletion_files[0])
+ dv = split.data_deletion_files[1]
+ self.assertEqual(
+ (dv.dv_index_path, dv.offset, dv.length, dv.cardinality),
+ ('dv/file-b', 2, 10, 3))
+
+ def test_decodes_min_max_keys_with_key_fields(self):
+ # Trimmed primary keys -> per-file min/max keys are decoded for PK
+ # merge-on-read. The golden files carry keys [1..10] and [11..20].
+ key_fields = [DataField(0, 'k', AtomicType('BIGINT'))]
+ split = deserialize_split_v1(
+ _GOLDEN_DATA_SPLIT_V1, self._partition_fields(), key_fields)
+ self.assertEqual([list(f.min_key.values) for f in split.files], [[1],
[11]])
+ self.assertEqual([list(f.max_key.values) for f in split.files], [[10],
[20]])
+
+ def test_keys_stay_empty_without_key_fields(self):
+ # Append tables pass no key fields; keys stay empty (unchanged
behavior).
+ split = deserialize_split_v1(_GOLDEN_DATA_SPLIT_V1,
self._partition_fields())
+ self.assertEqual([list(f.min_key.values) for f in split.files], [[],
[]])
+ self.assertEqual([list(f.max_key.values) for f in split.files], [[],
[]])
+
+ def test_deserialize_indexed_split_v1_golden(self):
+ # type id 3 -> IndexedSplit; row_ranges/scores must be preserved
(dropping
+ # them would make the reader scan the whole file, not the ANN/row-id
result).
+ split = deserialize_split_v1(_GOLDEN_INDEXED_SPLIT_V1,
self._partition_fields())
+ self.assertIsInstance(split, IndexedSplit)
+ self.assertIsNotNone(split.snapshot_id) # delegates to the inner
DataSplit
+ self.assertEqual(split.bucket, 3)
+ self.assertEqual([f.file_name for f in split.files], ['file-a',
'file-b'])
+ self.assertEqual(
+ [(r.from_, r.to) for r in split.row_ranges()], [(1, 4), (11, 13)])
+ self.assertEqual(split.scores(), [0.5, 0.25, 0.125])
+
+ def test_decode_modified_utf8_supplementary_char(self):
+ # Java writeUTF encodes U+1F600 as a CESU-8 surrogate pair; must
recombine.
+ self.assertEqual(
+ _decode_modified_utf8(bytes([0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80])),
+ '\U0001F600')
+
+ def test_decode_str_array_inline_and_pointer(self):
+ # Covers both element encodings: inline (<=7 bytes) and var pointer
(>7).
+ self.assertEqual(_decode_str_array(_BINARY_ARRAY_STR), ['id',
'longcolumn12'])
+ self.assertIsNone(_decode_str_array(None))
+ # Empty array: count 0 + empty null bitset.
+ self.assertEqual(_decode_str_array(bytes([0, 0, 0, 0])), [])
+
+ def test_bad_magic_and_version_raise(self):
+ good = _GOLDEN_DATA_SPLIT_V1
+ pf = self._partition_fields()
+ with self.assertRaisesRegex(ValueError, "magic"):
+ deserialize_split_v1(b'\x00' * 8 + good[8:], pf)
+ bad_version = good[:8] + (99).to_bytes(4, 'big') + good[12:]
+ with self.assertRaisesRegex(ValueError, "version"):
+ deserialize_split_v1(bad_version, pf)
+
+ def test_bad_indexed_magic_and_version_raise(self):
+ # IndexedSplit magic/version follow the 16-byte SplitSerializer header.
+ good = _GOLDEN_INDEXED_SPLIT_V1
+ pf = self._partition_fields()
+ with self.assertRaisesRegex(ValueError, "IndexedSplit magic"):
+ deserialize_split_v1(good[:16] + b'\x00' * 8 + good[24:], pf)
+ bad_version = good[:24] + (99).to_bytes(4, 'big') + good[28:]
+ with self.assertRaisesRegex(ValueError, "IndexedSplit version"):
+ deserialize_split_v1(bad_version, pf)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index f66717fb91..2fa5a3be7d 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -184,7 +184,7 @@ setup(
'paimon-vindex==0.1.0; python_version>="3.9"',
],
'sql': [
- 'pypaimon-rust; python_version>="3.10"',
+ 'pypaimon-rust>=0.3.0,<0.4.0; python_version>="3.10"',
'datafusion>=52; python_version>="3.10"',
],
'hdfs': [