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 43e9ad701e [python] Run native planning CI against paimon-rust main
(#9817)
43e9ad701e is described below
commit 43e9ad701ebee6d9af8316f35533f4d0836a1fc0
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 14 19:22:09 2026 +0800
[python] Run native planning CI against paimon-rust main (#9817)
---
.github/workflows/ci-python.yml | 77 +++++++++++++++++-----
paimon-python/pypaimon/read/split_serializer.py | 24 +++----
.../tests/manifest/manifest_manager_test.py | 25 ++++---
.../pypaimon/tests/pushdown_bucket_test.py | 2 +
.../pypaimon/tests/split_serializer_test.py | 68 ++++++++++++++++++-
5 files changed, 158 insertions(+), 38 deletions(-)
diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index daffd050ad..a33b09820e 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -28,7 +28,6 @@ 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: be5456e96b326939570b6ef5d63acddda3311c4f
jobs:
@@ -90,12 +89,6 @@ jobs:
java -version
mvn -version
- - name: Install Rust toolchain
- if: matrix.python-version == '3.11'
- run: |
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s --
-y --default-toolchain stable --profile minimal
- echo "$HOME/.cargo/bin" >> $GITHUB_PATH
-
- name: Verify Python version
run: python --version
@@ -155,14 +148,10 @@ jobs:
python -c "import datasets, lerobot; print('datasets',
datasets.__version__, 'lerobot', lerobot.__version__)"
fi
+ python -m pip install pypaimon-rust
if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
# Run the RoboMIND pipeline tests with synthetic local HDF5 data.
- # Exercise the 0.4 API in one lane until its wheel is published.
- python -m pip install
"git+https://github.com/apache/paimon-rust.git@${PYPAIMON_RUST_REV}#subdirectory=bindings/python"
python -m pip install "./paimon-python[sql]"
- python -c "import importlib.metadata as m; from
pypaimon_rust.datafusion import PaimonCatalog, Split; assert
m.version('pypaimon-rust') == '0.4.0'; assert hasattr(PaimonCatalog,
'get_table'); assert hasattr(Split, 'serialize')"
- else
- python -m pip install pypaimon-rust==0.3.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
@@ -180,11 +169,69 @@ jobs:
shell: bash
run: |
chmod +x paimon-python/dev/lint-python.sh
- if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
- export PYPAIMON_TEST_NATIVE_PLAN=1
- fi
./paimon-python/dev/lint-python.sh -e pytest_torch
+ rust-plan:
+ name: Rust Plan
+ timeout-minutes: 90
+ runs-on: ubuntu-latest
+ container: "python:3.11-slim"
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Install system dependencies
+ run: |
+ apt-get update && apt-get install -y --no-install-recommends \
+ build-essential git curl cmake pkg-config libssl-dev libclang-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+ - name: Install Rust toolchain
+ run: |
+ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s --
-y --default-toolchain stable --profile minimal
+ echo "$HOME/.cargo/bin" >> $GITHUB_PATH
+
+ - name: Build paimon-rust main
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install --upgrade
"git+https://github.com/apache/paimon-rust.git@main#subdirectory=bindings/python"
+
+ - name: Install Python test dependencies
+ run: |
+ python -m pip install torch --index-url
https://download.pytorch.org/whl/cpu
+ python -m pip install "./paimon-python[sql,rosbag]" \
+ readerwriterlock==1.0.9 fsspec==2024.3.1 cachetools==5.3.3
ossfs==2023.12.0 \
+ ray==2.54.0 fastavro==1.11.1 zstandard==0.24.0 polars==1.32.0
duckdb==1.3.2 \
+ pylance==0.39.0 'pytest~=7.0' py4j==0.10.9.9 parameterized==0.9.0 \
+ 'daft>=0.7.6' datasketches pyarrow==18.1.0 numpy==1.24.3
pandas==2.0.3 \
+ 'h5py>=3,<4' 'lumina-data>=${{ env.LUMINA_DATA_VERSION }}' \
+ vortex-data==0.70.0 'paimon-mosaic>=0.1.0'
+ python -m pip check
+
+ - name: Verify upstream native planning APIs
+ run: |
+ python - <<'PY'
+ import importlib.metadata as metadata
+ import json
+
+ from pypaimon_rust.datafusion import PaimonCatalog, ReadBuilder,
Split
+
+ distribution = metadata.distribution('pypaimon-rust')
+ source = json.loads(distribution.read_text('direct_url.json'))
+ print('pypaimon-rust', distribution.version,
+ 'commit', source['vcs_info']['commit_id'])
+ assert hasattr(PaimonCatalog, 'get_table'), 'Missing
PaimonCatalog.get_table'
+ assert hasattr(Split, 'serialize'), 'Missing Split.serialize'
+ assert hasattr(ReadBuilder, 'with_row_ranges'), 'Missing
ReadBuilder.with_row_ranges'
+ PY
+
+ - name: Run Python tests with Rust planning
+ shell: bash
+ env:
+ PYPAIMON_TEST_NATIVE_PLAN: '1'
+ run: |
+ bash paimon-python/dev/lint-python.sh -i pytest
+
torch:
name: PyTorch / Python 3.10
timeout-minutes: 60
diff --git a/paimon-python/pypaimon/read/split_serializer.py
b/paimon-python/pypaimon/read/split_serializer.py
index 4b50c86c28..ff5397c7c2 100644
--- a/paimon-python/pypaimon/read/split_serializer.py
+++ b/paimon-python/pypaimon/read/split_serializer.py
@@ -18,7 +18,7 @@
"""Deserialize the cross-language ``SplitSerializer`` v1 binary into a pypaimon
:class:`DataSplit`.
-Mirror of the Java ``DataSplit#serialize`` (VERSION 8) frame wrapped in the
+Mirror of the Java ``DataSplit#serialize`` (versions 8 and 9) 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.
@@ -46,16 +46,14 @@ _TYPE_INDEXED_SPLIT = 3
_INDEXED_SPLIT_MAGIC = -938472394838495695
_INDEXED_SPLIT_VERSION = 1
_SPLIT_MAGIC = -2394839472490812314
-_SPLIT_VERSION = 8
-
-_DFM_ARITY = 20
+_DFM_ARITY_BY_VERSION = {8: 20, 9: 21}
def _f(idx, name, dtype):
return DataField(idx, name, dtype)
-# DataFileMeta 20-field layout (order/types mirror
DataFileMetaSerializer#toRow).
+# DataFileMeta 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')),
@@ -78,10 +76,11 @@ _DFM_FIELDS: List[DataField] = [
_f(17, '_EXTERNAL_PATH', AtomicType('STRING')),
_f(18, '_FIRST_ROW_ID', AtomicType('BIGINT')),
_f(19, '_WRITE_COLS', AtomicType('BYTES')),
+ # Added in v9 for compaction/index refresh; unused by the Python reader.
+ _f(20, '_WRITE_COLS_SEQUENCES', AtomicType('BYTES')),
]
-# Arity is fixed by DataSplit VERSION 8; keep the field list and arity in
lockstep.
-assert len(_DFM_FIELDS) == _DFM_ARITY
+assert len(_DFM_FIELDS) == max(_DFM_ARITY_BY_VERSION.values())
def _decode_str_array(b: Optional[bytes]) -> Optional[List[str]]:
@@ -214,9 +213,9 @@ def _read_datasplit_body(r: _Reader, partition_fields:
List[DataField],
if r.i64() != _SPLIT_MAGIC:
raise ValueError("bad DataSplit magic")
version = r.i32()
- if version != _SPLIT_VERSION:
+ if version not in _DFM_ARITY_BY_VERSION:
raise ValueError(
- "unsupported DataSplit version %d (expected %d)" % (version,
_SPLIT_VERSION))
+ "unsupported DataSplit version %d (expected 8 or 9)" % 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()
@@ -228,7 +227,8 @@ def _read_datasplit_body(r: _Reader, partition_fields:
List[DataField],
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)
+ arity = _DFM_ARITY_BY_VERSION[version]
+ files = [_datafilemeta_from_row(r.take(r.i32()), bucket_path, arity,
key_fields)
for _ in range(file_count)]
data_deletion_files = _read_deletion_list(r)
r.u8() # isStreaming
@@ -250,9 +250,9 @@ def _decode_key(b: Optional[bytes], key_fields:
Optional[List[DataField]]) -> Ge
return GenericRowDeserializer.from_bytes(b, key_fields)
-def _datafilemeta_from_row(row_bytes: bytes, bucket_path: str,
+def _datafilemeta_from_row(row_bytes: bytes, bucket_path: str, arity: int,
key_fields: Optional[List[DataField]] = None) ->
DataFileMeta:
- row = BinaryRow(struct.pack('>i', _DFM_ARITY) + row_bytes, _DFM_FIELDS)
+ row = BinaryRow(struct.pack('>i', arity) + row_bytes, _DFM_FIELDS[:arity])
g = row.get_field
file_name = g(0)
external_path = g(17)
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
index 65be8ebdc5..f1def7a193 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
@@ -52,7 +52,7 @@ def _paimon_python_root():
def _runner_can_write_zstandard_avro():
- """fastavro uses ``backports.zstd`` (Py < 3.14) to *write* zstandard Avro
blocks."""
+ """Whether fastavro can write zstandard Avro blocks with the installed
backends."""
try:
from io import BytesIO
import fastavro
@@ -89,8 +89,8 @@ _MANIFEST_ZSTD_READ_SUBPROC_VENV_PYTHON = None
def _manifest_zstd_read_subprocess_venv_python():
"""Disposable venv with editable pypaimon for
``manifest_list_zstd_read_subprocess.py``.
- Does not install ``backports.zstd`` so the first worker run can hit
fastavro's missing zstd
- codec path when reading zstandard-compressed manifest lists.
+ The test removes the zstd backends before the first worker run, then
installs them
+ again to verify reading zstandard-compressed manifest lists in a fresh
process.
"""
global _MANIFEST_ZSTD_READ_SUBPROC_VENV_DIR,
_MANIFEST_ZSTD_READ_SUBPROC_VENV_PYTHON
with _MANIFEST_ZSTD_READ_SUBPROC_VENV_LOCK:
@@ -99,18 +99,21 @@ def _manifest_zstd_read_subprocess_venv_python():
repo = _paimon_python_root()
venv_dir = tempfile.mkdtemp(prefix='paimon-zstd-read-subprocess-')
pip_install_env = _subprocess_env_for_pip()
+ # Keep the codec behavior identical to the parent test environment.
+ fastavro_requirement = 'fastavro=={}'.format(fastavro.__version__)
uv_bin = shutil.which('uv')
try:
if uv_bin:
subprocess.check_call(
- [uv_bin, 'venv', venv_dir],
+ [uv_bin, 'venv', '--python', sys.executable, '--seed',
venv_dir],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=pip_install_env,
)
isolated_venv_python = _venv_python_executable(venv_dir)
subprocess.check_call(
- [uv_bin, 'pip', 'install', '-q', '--python',
isolated_venv_python, '-e', repo, 'requests'],
+ [uv_bin, 'pip', 'install', '-q', '--python',
isolated_venv_python,
+ '-e', repo, 'requests', fastavro_requirement],
env=pip_install_env,
)
else:
@@ -121,7 +124,8 @@ def _manifest_zstd_read_subprocess_venv_python():
)
isolated_venv_python = _venv_python_executable(venv_dir)
subprocess.check_call(
- [isolated_venv_python, '-m', 'pip', 'install', '-q', '-e',
repo, 'requests'],
+ [isolated_venv_python, '-m', 'pip', 'install', '-q', '-e',
repo,
+ 'requests', fastavro_requirement],
env=pip_install_env,
)
except Exception:
@@ -606,10 +610,10 @@ class ManifestListManagerTest(_ManifestManagerSetup):
sys.version_info >= (3, 13),
'fastavro >= 1.12 bundles zstd in compiled extension on Python 3.13+',
)
- def test_zstd_manifest_list_fastavro_requires_backports_zstd(self):
+ def test_zstd_manifest_list_requires_codec_backend(self):
"""Child venv runs ``manifest_list_zstd_read_subprocess`` (argv:
warehouse, table id, list file name).
- No ``backports.zstd`` in the venv → read fails; after ``pip install``
→ read succeeds.
+ No zstd backend in the venv → read fails; after ``pip install`` → read
succeeds.
"""
if not _runner_can_write_zstandard_avro():
self.skipTest('runner cannot write zstandard Avro')
@@ -629,7 +633,7 @@ class ManifestListManagerTest(_ManifestManagerSetup):
catalog_table_id = 'default.{}'.format(self._table_name)
isolated_venv_python = _manifest_zstd_read_subprocess_venv_python()
pip_install_env = _subprocess_env_for_pip()
- subprocess.run(
+ subprocess.check_call(
[isolated_venv_python, '-m', 'pip', 'uninstall', '-y',
'backports.zstd', 'zstandard'],
stdout=subprocess.DEVNULL,
@@ -654,7 +658,8 @@ class ManifestListManagerTest(_ManifestManagerSetup):
stderr_and_stdout = (
read_without_zstd_backend.stdout +
read_without_zstd_backend.stderr)
self.assertIn('zstandard codec is supported but you need to install',
stderr_and_stdout)
- self.assertIn('backports.zstd', stderr_and_stdout)
+ # fastavro 1.11 uses zstandard; newer versions use backports.zstd.
+ self.assertRegex(stderr_and_stdout, r"'(?:backports\.zstd|zstandard)'")
subprocess.check_call(
[isolated_venv_python, '-m', 'pip', 'install', '-q',
diff --git a/paimon-python/pypaimon/tests/pushdown_bucket_test.py
b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
index 13292d6860..8f3c0814cc 100644
--- a/paimon-python/pypaimon/tests/pushdown_bucket_test.py
+++ b/paimon-python/pypaimon/tests/pushdown_bucket_test.py
@@ -759,7 +759,9 @@ class BucketPruningIntegrationTest(unittest.TestCase):
"Equal on PK still narrows to the writer's bucket "
"even when AND'd with a non-bucket-key predicate")
+ @pytest.mark.python_plan
def test_manifest_bucket_pruning_skips_file_reads(self):
+ # This test observes Python FileIO calls; Rust uses its own file I/O.
table = self._create_pk_table('manifest_bucket_pruning')
for i in range(self.NUM_BUCKETS):
self._write(table, [{'id': i, 'val': i * 11}])
diff --git a/paimon-python/pypaimon/tests/split_serializer_test.py
b/paimon-python/pypaimon/tests/split_serializer_test.py
index d168c6ff4b..0c35e10640 100644
--- a/paimon-python/pypaimon/tests/split_serializer_test.py
+++ b/paimon-python/pypaimon/tests/split_serializer_test.py
@@ -16,6 +16,7 @@
# under the License.
import base64
+import struct
import unittest
from pypaimon.globalindex.indexed_split import IndexedSplit
@@ -56,7 +57,7 @@ _GOLDEN_DATA_SPLIT_V1 = base64.b64decode(
)
# Same split serialized as an IndexedSplit (type id 3): identical DataSplit
body
-# with a trailing row-ranges/scores section that the reader skips.
+# with a trailing row-ranges/scores section that must survive decoding.
_GOLDEN_INDEXED_SPLIT_V1 = base64.b64decode(
"U1BMSVRfVjEAAAABAAAAA/L54FRCJC4xAAAAAd7D0jAsGexmAAAACAAAAAAAAAAqAAAAHAAAAAIA"
"AAAAAAAAAOoHAAAAAAAABwAAAAAAAAAAAAADABRkdD0yMDI2MDcwNi9idWNrZXQtMwEAAAAIAAAA"
@@ -78,6 +79,26 @@ _GOLDEN_INDEXED_SPLIT_V1 = base64.b64decode(
)
+# Java DataSplitCompatibleTest's v9 golden, also used by paimon-rust's
+# serialize_matches_datasplit_v9 test. Includes the new non-null per-column
+# sequence numbers [15, 100, 150, 200] after write_cols.
+_GOLDEN_DATA_SPLIT_V9 = base64.b64decode(
+
"3sPSMCwZ7GYAAAAJAAAAAAAAABIAAAAUAAAAAQAAAAAAAAAAYWFhYWEAAIUAAAAUAAdteSBwYXRo"
+
"AQAAACAAAAAAAAAAAAEAAAJoAAAAAAAAAABteV9maWxlhwAAEAAAAAAAAAQAAAAAAAAUAAAAsAAA"
+
"ABQAAADIAAAAYAAAAOAAAACAAAAAQAEAAA8AAAAAAAAAyAAAAAAAAAAFAAAAAAAAAAMAAAAAAAAA"
+
"GAAAAMABAABgTEpMfwEAAAsAAAAAAAAAAQIEAAAAAIMBAAAAAAAAACAAAADYAQAAGQAAAPgBAAAM"
+
"AAAAAAAAACgAAAAYAgAAKAAAAEACAAAAAAABAAAAAAAAAABtaW5fa2V5hwAAAAAAAAABAAAAAAAA"
+
"AABtYXhfa2V5hwAAAAAAAAAAAAAAABQAAAAgAAAAFAAAADgAAAAQAAAAUAAAAAAAAAEAAAAAAAAA"
+
"AG1pbl9rZXmHAAAAAAAAAAEAAAAAAAAAAG1heF9rZXmHAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAA"
+
"AAAAACQAAAAgAAAAJAAAAEgAAAAQAAAAcAAAAAAAAAEAAAAAAAAAAAkAAAAQAAAAbWluX3ZhbHVl"
+
"AAAAAAAAAAAAAAAAAAABAAAAAAAAAAAJAAAAEAAAAG1heF92YWx1ZQAAAAAAAAAAAAAAAQAAAAAA"
+
"AAAAAAAAAAAAAAIAAAAAAAAAZXh0cmExAIZleHRyYTIAhgMAAAAAAAAAZmllbGQxAIZmaWVsZDIA"
+
"hmZpZWxkMwCGaGRmczovLy9wYXRoL3RvL3dhcmVob3VzZQAAAAAAAAAEAAAAAAAAAGEAAAAAAACB"
+
"YgAAAAAAAIFjAAAAAAAAgWYAAAAAAACBBAAAAAAAAAAPAAAAAAAAAGQAAAAAAAAAlgAAAAAAAADI"
+ "AAAAAAAAAAEAAAABAQANZGVsZXRpb25fZmlsZQAAAAAAAABkAAAAAAAAABYAAAAAAAAAIQAA"
+)
+
+
class SplitSerializerTest(unittest.TestCase):
def _partition_fields(self):
@@ -139,6 +160,51 @@ class SplitSerializerTest(unittest.TestCase):
_decode_modified_utf8(bytes([0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80])),
'\U0001F600')
+ def test_deserialize_v9_data_and_indexed_splits(self):
+ fields = [DataField(0, 's', AtomicType('STRING'))]
+ for indexed in (False, True):
+ with self.subTest(indexed=indexed):
+ if indexed:
+ data = (_GOLDEN_INDEXED_SPLIT_V1[:28] +
_GOLDEN_DATA_SPLIT_V9
+ + struct.pack('>iqqBif', 1, 13, 14, 1, 1, 0.75))
+ else:
+ data = _GOLDEN_DATA_SPLIT_V1[:16] + _GOLDEN_DATA_SPLIT_V9
+ split = deserialize_split_v1(data, fields, fields)
+ self.assertEqual(split.snapshot_id, 18)
+ self.assertEqual(list(split.partition.values), ['aaaaa'])
+ self.assertEqual(split.bucket, 20)
+ self.assertFalse(split.raw_convertible)
+ self.assertEqual(len(split.files), 1)
+ file = split.files[0]
+ self.assertEqual(file.file_name, 'my_file')
+ self.assertEqual(file.file_path, 'hdfs:///path/to/warehouse')
+ self.assertEqual((file.file_size, file.row_count), (1024 *
1024, 1024))
+ self.assertEqual(list(file.min_key.values), ['min_key'])
+ self.assertEqual(list(file.max_key.values), ['max_key'])
+ self.assertEqual((file.min_sequence_number,
file.max_sequence_number), (15, 200))
+ self.assertEqual((file.schema_id, file.level), (5, 3))
+ self.assertEqual(file.extra_files, ['extra1', 'extra2'])
+ self.assertEqual(file.embedded_index, bytes([1, 2, 4]))
+ self.assertEqual(file.value_stats_cols, ['field1', 'field2',
'field3'])
+ self.assertEqual(file.first_row_id, 12)
+ self.assertEqual(file.write_cols, ['a', 'b', 'c', 'f'])
+ self.assertEqual(len(split.data_deletion_files), 1)
+ dv = split.data_deletion_files[0]
+ self.assertEqual(
+ (dv.dv_index_path, dv.offset, dv.length, dv.cardinality),
+ ('deletion_file', 100, 22, 33))
+ if indexed:
+ self.assertEqual([(r.from_, r.to) for r in
split.row_ranges()], [(13, 14)])
+ self.assertEqual(split.scores(), [0.75])
+
+ def test_unsupported_data_split_version_raises(self):
+ for version in (7, 10):
+ with self.subTest(version=version):
+ data = (_GOLDEN_DATA_SPLIT_V1[:24] + struct.pack('>i', version)
+ + _GOLDEN_DATA_SPLIT_V1[28:])
+ with self.assertRaisesRegex(ValueError, 'unsupported DataSplit
version'):
+ deserialize_split_v1(data, self._partition_fields())
+
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'])