From 4c48fe1d3d916d8647697b7ada08a514c25f2bdc Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 16 Sep 2026 10:36:45 +0800 Subject: [PATCH 1/3] [python] Expand native planning across table contexts and scan modes --- paimon-python/README.md | 23 ++- paimon-python/pypaimon/read/native_plan.py | 52 +++++- .../scanner/chunk_shuffle_split_generator.py | 13 +- paimon-python/pypaimon/read/split_read.py | 10 +- paimon-python/pypaimon/read/table_scan.py | 145 +++++++++------ .../pypaimon/table/file_store_table.py | 2 +- .../tests/native_plan_chunk_shuffle_test.py | 172 ++++++++++++++++++ .../tests/native_plan_integration_test.py | 13 +- .../tests/native_plan_materialized_pk_test.py | 121 ++++++++++++ .../tests/native_plan_resolved_schema_test.py | 159 ++++++++++++++++ .../pypaimon/tests/native_plan_test.py | 36 +++- .../tests/schema_evolution_read_test.py | 53 +++++- 12 files changed, 720 insertions(+), 79 deletions(-) create mode 100644 paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py create mode 100644 paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py create mode 100644 paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py diff --git a/paimon-python/README.md b/paimon-python/README.md index 98dc4fc082af..91050439d915 100644 --- a/paimon-python/README.md +++ b/paimon-python/README.md @@ -50,6 +50,14 @@ Python planner for unsupported scans. New bindings preserve `plan.snapshot_id` even when pruning removes every split. Native explain output includes snapshot and split metadata; native pruning counters are not exposed. +With Rust main's `Table.from_resolved_schema()` binding, filesystem catalog +tables preserve the Python table's resolved schema and complete effective +options. Stale table objects, historical schemas, and `copy()` overrides or +option removals no longer require catalog reloading or Python planning. +Local tables opened with `FileStoreTable.from_path()` use the same path. +REST tables retain catalog loading for credentials and snapshot resolution; +custom catalog/FileIO contexts still fall back when they cannot be reproduced. + Explicit row ranges on data-evolution tables require `ReadBuilder.with_row_ranges()`. Watermark time travel requires Rust 0.4 or newer. Branch reads require the branch-aware binding exposing `Table.branch()`, and the resolved branch is @@ -90,14 +98,25 @@ bucket sharding. Cross-partition key migration is maintained by the writer's ind Batch first-row scans follow Java and exclude un-compacted level-0 files; they can use native planning. With deletion vectors, batch scans exclude level 0 unless `deletion-vectors.merge-on-read=true`, in which case overlapping key ranges stay -together for reader-side merging. Write scans and incremental scans retain level 0. +together when they include L0 and require reader-side merging. Fully materialized +DV files across levels use raw splits, including first-row clustering tables. +First-row plans that actually include L0 still fall back to Python. +Write scans and incremental scans retain level 0. + +Append and data-evolution chunk shuffle use Rust file and deletion-vector planning. +Python retains live-row chunk sizing, seeded shuffle order and balanced worker +assignment, so the same seed selects the same chunks with either planner. +Projection does not remove aligned column files before chunk construction. +Chunk shuffle supports partition predicates, deletion vectors and timestamp +incremental scans; its existing restrictions on limits, slices, row ranges and +global-index results still apply. Scored global-index results on data-evolution append tables use native row-range planning; Python attaches scores to the selected ranges and reads the data. Primary-key sorted indexes refine native batch splits through Python's existing index reader, preserving merge-required splits and the selected snapshot. -Chunk shuffle, query authorization, batch first-row scans explicitly including L0, +Query authorization, batch first-row plans containing L0, and precomputed primary-key global-index results still use the Python planner. Continuous streaming and write planning also retain their Python entrypoints. Native planning remains optional and is disabled by default. diff --git a/paimon-python/pypaimon/read/native_plan.py b/paimon-python/pypaimon/read/native_plan.py index 253eeabb8f30..c5b36e2c5b93 100644 --- a/paimon-python/pypaimon/read/native_plan.py +++ b/paimon-python/pypaimon/read/native_plan.py @@ -169,6 +169,41 @@ def _read_options(table) -> dict: return options +def _resolved_schema_file_io_options(table) -> Optional[dict]: + """FileIO properties for tables whose metadata needs no catalog resolution.""" + if not native_method_available('Table', 'from_resolved_schema'): + return None + 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 + # A custom environment or FileIO can supply metadata outside the path. + if type(environment) is not CatalogEnvironment or type(table.file_io) is not LocalFileIO: + 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} + if _catalog_metastore(loader) != 'filesystem': + # 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 + return _catalog_options(table) + + +def _resolved_schema_json(table) -> str: + 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} + options.update(_read_options(table)) + # The timestamp string has already been converted to epoch millis. + options.pop(CoreOptions.SCAN_TIMESTAMP.key(), 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'): @@ -231,14 +266,23 @@ def native_plan( 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()) + file_io_options = _resolved_schema_file_io_options(table) + if 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) + builder = rt.new_read_builder() + else: + from pypaimon_rust.datafusion import PaimonCatalog + rt = PaimonCatalog(_catalog_options(table)).get_table(table.identifier.get_full_name()) + builder = rt.new_read_builder(_read_options(table)) 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") - builder = rt.new_read_builder(_read_options(table)) if projection is not None: builder = builder.with_projection(projection) if predicate is not None: diff --git a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py index 89216322cfe1..33881bafcbed 100644 --- a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py +++ b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py @@ -230,12 +230,13 @@ def create_splits(self, file_entries: List[ManifestEntry]) -> List[Split]: if f.file_name in seen_paths: continue seen_paths.add(f.file_name) - f.set_file_path( - self.table.table_path, - partition_row, - bucket, - self.default_part_value, - ) + if not f.file_path: + f.set_file_path( + self.table.table_path, + partition_row, + bucket, + self.default_part_value, + ) for segments in self._slice_group_into_chunks(entries_in_group): all_chunks.append(_Chunk(partition_row, bucket, segments)) diff --git a/paimon-python/pypaimon/read/split_read.py b/paimon-python/pypaimon/read/split_read.py index ec1ee5668966..a5bad09a3986 100644 --- a/paimon-python/pypaimon/read/split_read.py +++ b/paimon-python/pypaimon/read/split_read.py @@ -583,7 +583,11 @@ def _is_reachable(name: str) -> bool: read_field for read_field in read_fields if _is_reachable(read_field) ] - read_predicate = trim_predicate_by_fields(self.push_down_predicate, read_file_fields) + # File readers filter physical column names before field-id schema + # normalization. A renamed or re-added name can identify a different + # column, so cross-schema filtering must run after normalization. + read_predicate = (trim_predicate_by_fields(self.push_down_predicate, read_file_fields) + if schema_id == self.table.table_schema.id else None) read_arrow_predicate = ( read_predicate.to_arrow() if read_predicate and self._arrow_filter_pushdown_enabled @@ -899,7 +903,9 @@ def create_reader(self) -> RecordReader: reader = concat_reader if (self.predicate_for_reader and (self.table.is_primary_key_table - or not self._arrow_filter_pushdown_enabled)): + or not self._arrow_filter_pushdown_enabled + or any(file.schema_id != self.table.table_schema.id + for file in self.split.files))): reader = FilterRecordBatchReader( reader, self.predicate_for_reader, diff --git a/paimon-python/pypaimon/read/table_scan.py b/paimon-python/pypaimon/read/table_scan.py index 7e4889da51ab..de76ce0fd784 100755 --- a/paimon-python/pypaimon/read/table_scan.py +++ b/paimon-python/pypaimon/read/table_scan.py @@ -115,25 +115,26 @@ def _native_plan_supported(self) -> bool: def _native_plan_supported_impl(self) -> bool: """Fall back to the Python scanner for scans native can't carry: - chunk-shuffle, primary-key global-index results, first-row scans which include L0, + primary-key global-index results, a primary-key table whose trimmed PK is empty (PK equals the partition - key; Rust rejects this schema), a stale - schema without time travel, removed copy() options which Rust cannot - represent, unsupported time travel selectors, + key; Rust rejects this schema), unsupported time travel selectors, + and catalog-loaded tables with schema/option overrides Rust cannot carry, query auth, 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_method_available, native_runtime_available, native_version_at_least, + _resolved_schema_file_io_options, native_method_available, + native_runtime_available, native_version_at_least, ) if not native_runtime_available(): return False fs = self.file_scanner if fs.is_streaming and not native_method_available('Split', 'is_streaming'): return False - if (getattr(fs, 'chunk_shuffle', None) is not None - or not self._native_global_index_result_supported()): + if not self._native_global_index_result_supported(): return False + if getattr(fs, 'chunk_shuffle', None) is not None: + fs._validate_chunk_shuffle_compat() # Positional append distribution needs the stable partition/file order # introduced in 0.4. Older bindings can assign different rows per call. if (not self.table.is_primary_key_table and not fs.data_evolution @@ -157,35 +158,31 @@ def _native_plan_supported_impl(self) -> bool: if (self.table.current_branch() != 'main' and not native_method_available('Table', 'branch')): 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 + resolved_schema = _resolved_schema_file_io_options(self.table) is not None + if not resolved_schema: + 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: return False - # Ordinary Java first-row batch scans skip L0. The clustering override - # can combine first-row with DV merge-on-read, but its files need a - # separate audit because they may be sorted by non-primary-key columns. - if (self.table.options.merge_engine() == 'first-row' - and not fs.skip_level0 and not fs.is_streaming): - return False # Rust rejects schemas whose primary keys are all partition keys. if getattr(self.table, 'is_primary_key_table', False) \ and not self.table.trimmed_primary_keys: @@ -199,23 +196,24 @@ def _native_plan_supported_impl(self) -> bool: from pypaimon.read.native_plan import native_family_search_modes_available if not native_family_search_modes_available(): return False - supported_time_travel = any( - options.contains_key(key) for key in _NATIVE_TIME_TRAVEL_OPTIONS) - # Time travel intentionally carries a historical schema; other stale - # table objects must still fall back because Rust reloads the latest. - latest_schema = self.table.schema_manager.latest() - if (not supported_time_travel and latest_schema is not None - and latest_schema.id != self.table.table_schema.id): - return False - # Rust cannot remove an option persisted in the catalog-loaded schema. - applied_options = getattr(self.table, '_applied_dynamic_options', {}) or {} - allowed_options = ( - _NATIVE_FORWARDED_OPTIONS | _NATIVE_PLAN_INDEPENDENT_OPTIONS) - if (set(applied_options) - allowed_options - or any(key in (_NATIVE_TIME_TRAVEL_OPTIONS - | _NATIVE_SEARCH_MODE_OPTIONS) and value is None - for key, value in applied_options.items())): - return False + if not resolved_schema: + supported_time_travel = any( + options.contains_key(key) for key in _NATIVE_TIME_TRAVEL_OPTIONS) + # Time travel intentionally carries a historical schema; other stale + # table objects must still fall back because Rust reloads the latest. + latest_schema = self.table.schema_manager.latest() + if (not supported_time_travel and latest_schema is not None + and latest_schema.id != self.table.table_schema.id): + return False + # Rust cannot remove an option persisted in the catalog-loaded schema. + applied_options = getattr(self.table, '_applied_dynamic_options', {}) or {} + allowed_options = ( + _NATIVE_FORWARDED_OPTIONS | _NATIVE_PLAN_INDEPENDENT_OPTIONS) + if (set(applied_options) - allowed_options + or any(key in (_NATIVE_TIME_TRAVEL_OPTIONS + | _NATIVE_SEARCH_MODE_OPTIONS) and value is None + for key, value in applied_options.items())): + return False from pypaimon.snapshot.time_travel_util import SCAN_KEYS unsupported_scan_keys = set(SCAN_KEYS) - _NATIVE_TIME_TRAVEL_OPTIONS if any(options.contains_key(k) for k in unsupported_scan_keys): @@ -261,7 +259,8 @@ def _try_native_plan(self) -> Optional[Plan]: if self._incremental_snapshot_range is None: return Plan([]) extra_options['incremental_range'] = self._incremental_snapshot_range - if has_distribution and fs.data_evolution: + chunk_shuffle = fs.chunk_shuffle + if has_distribution and fs.data_evolution and chunk_shuffle is None: if fs.idx_of_this_subtask is not None: extra_options['row_position_shard'] = ( fs.idx_of_this_subtask, fs.number_of_para_subtasks) @@ -283,11 +282,17 @@ def _try_native_plan(self) -> Optional[Plan]: limit=None if has_distribution else self.limit, projection=( [field.name for field in self._read_type] - if self._read_type is not None else None), + if self._read_type is not None and chunk_shuffle is None else None), row_ranges=row_ranges, **extra_options, ) splits = plan.splits() + if (self.table.options.merge_engine() == 'first-row' + and not fs.skip_level0 and not fs.is_streaming + and any(file.level == 0 for split in splits for file in split.files)): + # Materialized clustered files read raw. Mixing L0 with files + # sorted by clustering columns still needs a reader audit. + return None partition_predicate = self.file_scanner.partition_key_predicate if partition_predicate is not None: splits = [s for s in splits @@ -298,7 +303,9 @@ def _try_native_plan(self) -> Optional[Plan]: and plan.snapshot_id is not None): snapshot = self.table.snapshot_manager().get_snapshot_by_id(plan.snapshot_id) splits = fs._apply_primary_key_sorted_indexes(splits, snapshot) - if has_distribution: + if chunk_shuffle is not None: + splits = self._chunk_shuffle_splits(splits, plan.snapshot_id) + elif has_distribution: if self.table.is_primary_key_table: splits = [s for s in splits if s.bucket % fs.number_of_para_subtasks == fs.idx_of_this_subtask] @@ -328,6 +335,36 @@ def _try_native_plan(self) -> Optional[Plan]: "Native plan failed, falling back to the Python scanner: %s", e) return None + def _chunk_shuffle_splits(self, splits, snapshot_id): + """Reuse native file/DV planning with Python's stable chunk assignment.""" + from pypaimon.manifest.schema.manifest_entry import ManifestEntry + from pypaimon.read.scanner.chunk_shuffle_split_generator import ( + AppendChunkShuffleSplitGenerator, DataEvolutionChunkShuffleSplitGenerator, + ) + fs = self.file_scanner + entries, deletions = [], {} + for split in splits: + key = (tuple(split.partition.values), split.bucket) + for index, file in enumerate(split.files): + entries.append(ManifestEntry( + 0, split.partition, split.bucket, self.table.total_buckets, file)) + if split.data_deletion_files and split.data_deletion_files[index] is not None: + deletions.setdefault(key, {})[file.file_name] = split.data_deletion_files[index] + generator_type = (DataEvolutionChunkShuffleSplitGenerator if fs.data_evolution + else AppendChunkShuffleSplitGenerator) + seed, chunk_size = fs.chunk_shuffle + generator = generator_type(self.table, fs.target_split_size, fs.open_file_cost, + deletions, seed=seed, chunk_size=chunk_size) + if fs.idx_of_this_subtask is not None: + generator.with_shard(fs.idx_of_this_subtask, fs.number_of_para_subtasks) + chunks = generator.create_splits(entries) + for split in chunks: + while callable(getattr(split, 'data_split', None)): + split = split.data_split() + split.snapshot_id = snapshot_id + split.is_streaming = fs.is_streaming + return chunks + def plan_for_write(self) -> Plan: if self.__auth_query() is not None: raise TableNoPermissionException(self.table.identifier) diff --git a/paimon-python/pypaimon/table/file_store_table.py b/paimon-python/pypaimon/table/file_store_table.py index 5519ee087dc4..3c4a8b1c34aa 100644 --- a/paimon-python/pypaimon/table/file_store_table.py +++ b/paimon-python/pypaimon/table/file_store_table.py @@ -69,7 +69,7 @@ def from_path(cls, table_path: str) -> 'FileStoreTable': Create a FileStoreTable from a table path. This is useful for reading tables created by Java without going through a catalog. """ - file_io = FileIO(table_path, Options({})) + file_io = FileIO.get(table_path, Options({})) schema_manager = SchemaManager(file_io, table_path) table_schema = schema_manager.latest() diff --git a/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py b/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py new file mode 100644 index 000000000000..c2755aca1755 --- /dev/null +++ b/paimon-python/pypaimon/tests/native_plan_chunk_shuffle_test.py @@ -0,0 +1,172 @@ +# 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 json +from collections import Counter +from unittest.mock import patch + +import pyarrow as pa +import pytest + +from pypaimon import CatalogFactory, Schema +from pypaimon.deletionvectors.bitmap_deletion_vector import BitmapDeletionVector +from pypaimon.read.native_plan import native_runtime_available +from pypaimon.write.commit_message import CommitMessage +from pypaimon.write.table_delete import TableDeleteByRowId + + +pytestmark = [pytest.mark.native_plan, pytest.mark.skipif( + not native_runtime_available(), reason='Rust planner required')] + + +@pytest.fixture(params=['append', 'append-dv', 'de', 'de-dv']) +def chunk_table(request, tmp_path): + de = request.param.startswith('de') + dv = request.param.endswith('dv') + fields = [('id', pa.int64()), ('p', pa.string())] + options = {'file.format': 'parquet', 'source.split.target-size': '1b', + 'source.split.open-file-cost': '1b'} + if de: + fields.append(('payload', pa.large_binary())) + options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true', + 'blob.target-file-size': '1b'}) + if dv: + options['deletion-vectors.enabled'] = 'true' + schema = pa.schema(fields) + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + schema, partition_keys=['p'], options=options), False) + table = catalog.get_table('default.t') + expected = [] + for start in (0, 6): + rows = [{'id': i, 'p': (None, 'a', 'b')[i % 3]} for i in range(start, start + 6)] + if de: + for row in rows: + row['payload'] = ('payload-%d' % row['id']).encode() + expected.extend(rows) + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist(rows, schema=schema)) + commit.commit(writer.prepare_commit()) + finally: + writer.close() + commit.close() + for snapshot_id in (1, 2): + path = table.snapshot_manager().get_snapshot_path(snapshot_id) + snapshot = json.loads(table.file_io.read_file_utf8(path)) + snapshot['timeMillis'] = snapshot_id * 100 + table.file_io.write_file(path, json.dumps(snapshot), overwrite=True) + before_deletes = list(expected) + if dv: + builder = table.copy({'scan.native-plan.enabled': 'false'}).new_read_builder() + plan = builder.new_scan().plan() + index_adds = [] + for split in plan.splits(): + # Delete the first physical row in every main file. Sidecar files + # inherit the anchor's DV through their aligned row-id group. + for file in split.files: + if not file.file_name.endswith('.parquet'): + continue + from pypaimon.read.split import DataSplit + one = DataSplit(files=[file], partition=split.partition, bucket=split.bucket, + raw_convertible=not de) + row = builder.with_projection(['id']).new_read().to_arrow([one]).to_pylist()[0] + expected = [candidate for candidate in expected if candidate['id'] != row['id']] + vector = BitmapDeletionVector() + vector.delete(0) + index_adds.append((tuple(split.partition.values), split.bucket, + TableDeleteByRowId(table)._write_deletion_vector_index( + split.partition, split.bucket, {file.file_name: vector}))) + commit = table.new_batch_write_builder().new_commit() + try: + commit.commit([CommitMessage(partition=partition, bucket=bucket, new_files=[], + index_adds=[entry]) for partition, bucket, entry in index_adds]) + finally: + commit.close() + return table, expected, before_deletes + + +def _chunks(table, seed, chunk_size=3, shard=None, predicate=None, projection=None): + results = [] + for native in (False, True): + builder = table.copy({'scan.native-plan.enabled': str(native).lower()}).new_read_builder() + if predicate is not None: + builder.with_filter(predicate) + if projection is not None: + builder.with_projection(projection) + scan = builder.new_scan().with_chunk_shuffle(seed, chunk_size) + if shard is not None: + scan.with_shard(*shard) + if native: + with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')), \ + patch.object(scan.file_scanner, 'plan_files', side_effect=AssertionError('Python manifest scan')): + plan = scan.plan() + else: + plan = scan.plan() + chunks = [] + for split in plan.splits(): + if table.options.options.contains_key('incremental-between-timestamp'): + assert split.is_streaming + rows = builder.new_read().to_arrow([split]).to_pylist() + assert 0 < len(rows) <= chunk_size + assert split.merged_row_count() == len(rows) + if rows and 'p' in rows[0]: + assert len({row['p'] for row in rows}) == 1 + chunks.append(sorted(rows, key=lambda row: row['id'])) + results.append((plan.snapshot_id, chunks)) + assert results[0] == results[1] + return results[1] + + +@pytest.mark.parametrize('seed', [-11, 42, 2 ** 70]) +def test_native_chunks_preserve_order_live_counts_and_worker_assignment(chunk_table, seed): + table, expected, _ = chunk_table + snapshot_id, chunks = _chunks(table, seed) + actual = [row for chunk in chunks for row in chunk] + assert sorted(actual, key=lambda row: row['id']) == expected + workers = [] + for worker in range(5): + sid, assigned = _chunks(table, seed, shard=(worker, 5)) + assert sid == snapshot_id + workers.extend(assigned) + assert workers == chunks + assert Counter(row['id'] for chunk in workers for row in chunk) == Counter(row['id'] for row in expected) + + +def test_native_chunks_keep_partition_filter_projection_and_time_travel(chunk_table): + table, expected, before_deletes = chunk_table + pb = table.new_read_builder().new_predicate_builder() + for predicate, partition in ((pb.equal('p', 'a'), 'a'), (pb.is_null('p'), None)): + _, chunks = _chunks(table, 7, predicate=predicate, projection=['id']) + assert sorted(row['id'] for chunk in chunks for row in chunk) == [ + row['id'] for row in expected if row['p'] == partition] + sid, chunks = _chunks(table.copy({'scan.snapshot-id': '2'}), 7) + assert sid == 2 + assert sorted([row for chunk in chunks for row in chunk], key=lambda row: row['id']) == before_deletes + sid, chunks = _chunks(table, 7, predicate=pb.equal('p', 'missing')) + assert sid == table.snapshot_manager().get_latest_snapshot().id + assert chunks == [] + + +def test_native_incremental_chunks_keep_events_and_ignore_later_deletions(chunk_table): + table, _, before_deletes = chunk_table + incremental = table.copy({'incremental-between-timestamp': '0,200'}) + sid, chunks = _chunks(incremental, 42) + assert sid == 2 + assert sorted([row for chunk in chunks for row in chunk], key=lambda row: row['id']) == before_deletes diff --git a/paimon-python/pypaimon/tests/native_plan_integration_test.py b/paimon-python/pypaimon/tests/native_plan_integration_test.py index 8f1dcd257cca..10cc5d3471f5 100644 --- a/paimon-python/pypaimon/tests/native_plan_integration_test.py +++ b/paimon-python/pypaimon/tests/native_plan_integration_test.py @@ -112,15 +112,22 @@ def test_pk_equal_to_partition_key_falls_back(self): 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. + def test_copy_removed_persisted_scan_option_uses_native(self): + # The resolved schema must replace, rather than merge, persisted options. 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) + builder = native.new_read_builder() + scan = builder.new_scan() + with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')): + plan = scan.plan() + self.assertEqual(plan.snapshot_id, 2) + self.assertEqual(sorted(builder.new_read().to_arrow(plan.splits()).to_pylist(), + key=lambda row: row['k']), + [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}]) def test_first_row_batch_scan_uses_native_plan(self): self.cat.create_table('default.fr_t', Schema.from_pyarrow_schema( diff --git a/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py b/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py new file mode 100644 index 000000000000..72399078dc22 --- /dev/null +++ b/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py @@ -0,0 +1,121 @@ +# 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. + +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +import pyarrow as pa +import pyarrow.parquet as pq +import pytest + +from pypaimon import CatalogFactory, Schema +from pypaimon.deletionvectors.bitmap_deletion_vector import BitmapDeletionVector +from pypaimon.read.native_plan import native_runtime_available +from pypaimon.table.row.generic_row import GenericRow +from pypaimon.write.commit_message import CommitMessage +from pypaimon.write.table_delete import TableDeleteByRowId + + +pytestmark = [pytest.mark.native_plan, pytest.mark.skipif( + not native_runtime_available(), reason='Rust planner required')] + + +@pytest.mark.parametrize('engine', ['deduplicate', 'first-row']) +@pytest.mark.parametrize('merge_on_read', ['false', 'true']) +@pytest.mark.parametrize('target_size', ['1b', '1mb']) +def test_clustered_materialized_dv_files_use_native_raw_splits(tmp_path, engine, merge_on_read, target_size): + schema = pa.schema([('id', pa.int64()), ('value', pa.string())]) + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + schema, primary_keys=['id'], options={ + 'bucket': '1', 'merge-engine': engine, 'file.format': 'parquet', + 'deletion-vectors.enabled': 'true', 'deletion-vectors.merge-on-read': merge_on_read, + 'pk-clustering-override': 'true', 'clustering.columns': 'value', + 'source.split.target-size': target_size, 'source.split.open-file-cost': '1b', + }), False) + table = catalog.get_table('default.t') + expected = [{'id': i, 'value': 'v%d' % (10 - i)} for i in range(1, 5)] + files = [] + for level, keys in ((1, (1, 3)), (2, (2, 4))): + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist([expected[key - 1] for key in keys], schema=schema)) + messages = writer.prepare_commit() + for message in messages: + rewritten = [] + for file in message.new_files: + path = table.path_factory().bucket_path((), 0) + '/' + file.file_name + # Simulate Java clustering compaction: physical order is by + # value, opposite to PK order, while min/max PK ranges overlap. + data = pq.read_table(path).sort_by([('value', 'ascending')]) + pq.write_table(data, path) + rewritten.append(replace(file, level=level, + file_size=Path(path).stat().st_size)) + message.new_files = rewritten + files.extend(rewritten) + commit.commit(messages) + finally: + writer.close() + commit.close() + + vector = BitmapDeletionVector() + vector.delete(0) # clustered first file starts with id=3 + entry = TableDeleteByRowId(table)._write_deletion_vector_index( + GenericRow([], []), 0, {files[0].file_name: vector}) + commit = table.new_batch_write_builder().new_commit() + try: + commit.commit([CommitMessage(partition=(), bucket=0, new_files=[], index_adds=[entry])]) + finally: + commit.close() + expected = [row for row in expected if row['id'] != 3] + pb = table.new_read_builder().new_predicate_builder() + for predicate in (None, pb.equal('id', 3), pb.equal('value', 'v8')): + for native in (False, True): + builder = table.copy({'scan.native-plan.enabled': str(native).lower()}).new_read_builder() + if predicate is not None: + builder.with_filter(predicate) + scan = builder.new_scan() + if native: + with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')): + plan = scan.plan() + else: + plan = scan.plan() + assert all(split.raw_convertible for split in plan.splits()) + rows = builder.new_read().to_arrow(plan.splits()).to_pylist() + wanted = expected if predicate is None else ([] if predicate.field == 'id' else [expected[1]]) + assert sorted(rows, key=lambda row: row['id']) == wanted + assert plan.snapshot_id == 3 + if predicate is None: + assert len(plan.splits()) == (2 if target_size == '1b' else 1) + + if engine == 'first-row' and merge_on_read == 'true': + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist([{'id': 5, 'value': 'pending'}], schema=schema)) + commit.commit(writer.prepare_commit()) + finally: + writer.close() + commit.close() + scan = table.copy({'scan.native-plan.enabled': 'true'}).new_read_builder().new_scan() + with patch.object(scan.file_scanner, 'scan', wraps=scan.file_scanner.scan) as fallback: + plan = scan.plan() + fallback.assert_called_once_with() + assert any(file.level == 0 for split in plan.splits() for file in split.files) diff --git a/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py new file mode 100644 index 000000000000..70aea01f5a66 --- /dev/null +++ b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py @@ -0,0 +1,159 @@ +# 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. + +from unittest.mock import patch + +import pyarrow as pa +import pytest + +from pypaimon import CatalogFactory, Schema +from pypaimon.common.identifier import Identifier +from pypaimon.read.native_plan import native_runtime_available +from pypaimon.schema.data_types import AtomicType +from pypaimon.schema.schema_change import SchemaChange +from pypaimon.table.file_store_table import FileStoreTable + + +pytestmark = [pytest.mark.native_plan, pytest.mark.skipif( + not native_runtime_available(), reason='Rust planner required')] + + +@pytest.fixture(params=['append', 'pk', 'de']) +def source(request, tmp_path): + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + schema = pa.schema([('id', pa.int64()), ('value', pa.string())]) + options = {'file.format': 'parquet'} + if request.param == 'de': + options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true'}) + if request.param == 'pk': + options['bucket'] = '1' + catalog.create_table('default.t', Schema.from_pyarrow_schema( + schema, options=options, primary_keys=['id'] if request.param == 'pk' else []), False) + table = catalog.get_table('default.t') + _write(table, [{'id': 1, 'value': 'old'}]) + return catalog, table + + +def _write(table, rows): + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist(rows)) + commit.commit(writer.prepare_commit()) + finally: + writer.close() + commit.close() + + +def _read(table, native, predicate=None, projection=None): + table = table.copy_without_time_travel({'scan.native-plan.enabled': str(native).lower()}) + builder = table.new_read_builder() + if predicate is not None: + builder.with_filter(predicate) + if projection is not None: + builder.with_projection(projection) + scan = builder.new_scan() + if native: + with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')), \ + patch.object(table.schema_manager, 'latest', side_effect=AssertionError('schema reload')), \ + patch('pypaimon_rust.datafusion.PaimonCatalog', side_effect=AssertionError('catalog reload')): + plan = scan.plan() + else: + plan = scan.plan() + rows = builder.new_read().to_arrow(plan.splits()).to_pylist() + return plan.snapshot_id, sorted(rows, key=lambda row: row['id']) + + +def _assert_parity(table, expected, snapshot_id, **kwargs): + for native in (False, True): + assert _read(table, native, **kwargs) == (snapshot_id, expected) + + +def test_stale_schema_after_column_rename(source): + catalog, stale = source + catalog.alter_table('default.t', [SchemaChange.rename_column('value', 'renamed')], False) + latest = catalog.get_table('default.t') + _write(latest, [{'id': 2, 'renamed': 'new'}]) + assert stale.table_schema.id != latest.table_schema.id + for table, name in ((stale, 'value'), (latest, 'renamed')): + _assert_parity(table, [{'id': 1, name: 'old'}, {'id': 2, name: 'new'}], 2) + predicate = table.new_read_builder().new_predicate_builder().equal(name, 'new') + _assert_parity(table, [{'id': 2}], 2, predicate=predicate, projection=['id']) + + +def test_stale_schema_does_not_confuse_readded_column(source): + catalog, stale = source + catalog.alter_table('default.t', [SchemaChange.drop_column('value')], False) + catalog.alter_table('default.t', [SchemaChange.add_column('value', AtomicType('STRING'))], False) + latest = catalog.get_table('default.t') + _write(latest, [{'id': 2, 'value': 'new'}]) + assert stale.fields[1].id != latest.fields[1].id + _assert_parity(stale, [{'id': 1, 'value': 'old'}, {'id': 2, 'value': None}], 2) + _assert_parity(latest, [{'id': 1, 'value': None}, {'id': 2, 'value': 'new'}], 2) + for table, value, id_ in ((stale, 'old', 1), (latest, 'new', 2)): + predicate = table.new_read_builder().new_predicate_builder().equal('value', value) + _assert_parity(table, [{'id': id_}], 2, predicate=predicate, projection=['id']) + + +@pytest.mark.parametrize('uri', [False, True], ids=['path', 'file-uri']) +def test_catalogless_table_uses_resolved_schema(source, uri): + from pathlib import Path + _, table = source + location = Path(table.table_path).as_uri() if uri else table.table_path + direct = FileStoreTable.from_path(location) + assert direct.catalog_environment.catalog_loader is None + _assert_parity(direct, [{'id': 1, 'value': 'old'}], 1) + + +def test_dotted_database_does_not_require_catalog_parsing(source): + _, table = source + resolved = FileStoreTable(table.file_io, Identifier('namespace.database', 't'), + table.table_path, table.table_schema) + _assert_parity(resolved, [{'id': 1, 'value': 'old'}], 1) + + +def test_copy_removes_search_options_and_normalizes_values(source): + catalog, table = source + catalog.alter_table('default.t', [SchemaChange.set_option('scalar-index.search-mode', 'full')], False) + table = catalog.get_table('default.t').copy({ + 'scalar-index.search-mode': None, 'read.batch-size': 1, 'metadata.stats-mode': 'none'}) + _assert_parity(table, [{'id': 1, 'value': 'old'}], 1) + + +def test_historical_schema_remains_resolved_after_selector_removal(source): + catalog, old = source + catalog.alter_table('default.t', [SchemaChange.rename_column('value', 'renamed')], False) + latest = catalog.get_table('default.t') + _write(latest, [{'id': 2, 'renamed': 'new'}]) + historical = latest.copy({'scan.snapshot-id': '1'}) + assert historical.field_names == old.field_names + resumed = historical.copy_without_time_travel({'scan.snapshot-id': None}) + _assert_parity(resumed, [{'id': 1, 'value': 'old'}, {'id': 2, 'value': 'new'}], 2) + + +def test_copy_merge_engine_changes_level_zero_visibility(tmp_path): + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + pa.schema([('id', pa.int64()), ('value', pa.string())]), primary_keys=['id'], + options={'bucket': '1', 'file.format': 'parquet'}), False) + table = catalog.get_table('default.t') + _write(table, [{'id': 1, 'value': 'old'}]) + first_row = table.copy({'merge-engine': 'first-row'}) + _assert_parity(first_row, [], 1) + _assert_parity(first_row.copy({'merge-engine': None}), [{'id': 1, 'value': 'old'}], 1) diff --git a/paimon-python/pypaimon/tests/native_plan_test.py b/paimon-python/pypaimon/tests/native_plan_test.py index 315e15b91e09..5d6dd8d1d01e 100644 --- a/paimon-python/pypaimon/tests/native_plan_test.py +++ b/paimon-python/pypaimon/tests/native_plan_test.py @@ -106,6 +106,39 @@ def setUp(self): version_patcher.start() self.addCleanup(version_patcher.stop) + def test_resolved_schema_keeps_custom_io_and_rest_on_catalog_path(self): + from pypaimon.catalog.catalog_environment import CatalogEnvironment + from pypaimon.filesystem.local_file_io import LocalFileIO + from pypaimon.read.native_plan import _resolved_schema_file_io_options + + class CustomIO(LocalFileIO): + pass + + class CustomEnvironment(CatalogEnvironment): + pass + + class CustomLoader(FileSystemCatalogLoader): + pass + + table = Mock(file_io=LocalFileIO(), catalog_environment=CatalogEnvironment.empty()) + with patch('pypaimon.read.native_plan.native_method_available', return_value=True): + self.assertEqual(_resolved_schema_file_io_options(table), {}) + table.file_io = CustomIO() + self.assertIsNone(_resolved_schema_file_io_options(table)) + table.file_io = LocalFileIO() + table.catalog_environment = CustomEnvironment() + self.assertIsNone(_resolved_schema_file_io_options(table)) + table.catalog_environment = CatalogEnvironment.empty() + for loader_type in (RESTCatalogLoader, CustomLoader): + table.catalog_environment.catalog_loader = loader_type( + CatalogContext.create_from_options(Options({}))) + self.assertIsNone(_resolved_schema_file_io_options(table)) + for attr in ('hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'): + context = CatalogContext.create_from_options(Options({})) + setattr(context, attr, object()) + table.catalog_environment.catalog_loader = FileSystemCatalogLoader(context) + self.assertIsNone(_resolved_schema_file_io_options(table)) + def test_switch_defaults_off(self): self.assertFalse(CoreOptions(Options({})).native_plan_enabled()) self.assertTrue( @@ -281,7 +314,6 @@ def check(setup): setattr(fs, 'idx_of_this_subtask', 0))) check(lambda s, fs: (setattr(fs, 'data_evolution', True), 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, '_row_ranges', [object()])) check(lambda s, fs: setattr(fs, 'deletion_vectors_enabled', True)) @@ -293,8 +325,6 @@ def check(setup): 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: 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__( diff --git a/paimon-python/pypaimon/tests/schema_evolution_read_test.py b/paimon-python/pypaimon/tests/schema_evolution_read_test.py index 7bbee3d84ab8..916058812c82 100644 --- a/paimon-python/pypaimon/tests/schema_evolution_read_test.py +++ b/paimon-python/pypaimon/tests/schema_evolution_read_test.py @@ -519,11 +519,9 @@ def test_schema_evolution_with_read_filter(self): table_read = read_builder.new_read() actual = table_read.to_arrow(splits) + # Old files supply NULL for behavior; NULL = 'g' cannot pass AND. expected = pa.Table.from_pydict({ - 'user_id': [1, 2, 4, 3, 7], - 'item_id': [1001, 1002, 1004, 1003, 1007], - 'dt': ["p1", "p1", "p1", "p2", "p2"], - 'behavior': [None, None, None, None, "g"], + 'user_id': [7], 'item_id': [1007], 'dt': ["p2"], 'behavior': ["g"], }, schema=pa_schema) self.assertEqual(expected, actual) @@ -873,3 +871,50 @@ def _write_test_table(self, table): def _scan_table(self, read_builder): splits = read_builder.new_scan().plan().splits() return splits + + +@pytest.mark.parametrize('file_format', ['parquet', 'avro']) +@pytest.mark.parametrize('evolution', ['rename', 'readd']) +def test_cross_schema_filter_runs_after_field_id_mapping(tmp_path, file_format, evolution): + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + schema = pa.schema([('k', pa.int64()), ('v', pa.string())]) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + schema, options={'file.format': file_format}), False) + + def write(table, values): + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist(values)) + commit.commit(writer.prepare_commit()) + finally: + writer.close() + commit.close() + + write(catalog.get_table('default.t'), [{'k': 1, 'v': 'a'}, {'k': 2, 'v': 'b'}]) + if evolution == 'rename': + catalog.alter_table('default.t', [SchemaChange.rename_column('v', 'renamed')], False) + latest_rows = [{'k': 3, 'renamed': 'c'}, {'k': 4, 'renamed': 'b'}] + else: + catalog.alter_table('default.t', [SchemaChange.drop_column('v')], False) + catalog.alter_table('default.t', [SchemaChange.add_column('v', AtomicType('STRING'))], False) + latest_rows = [{'k': 3, 'v': 'b'}, {'k': 4, 'v': None}] + table = catalog.get_table('default.t').copy({'scan.native-plan.enabled': 'false'}) + write(table, latest_rows) + # Read an unfiltered plan so manifest statistics cannot hide a reader bug. + splits = table.new_read_builder().new_scan().plan().splits() + pb = table.new_read_builder().new_predicate_builder() + cases = ([(pb.equal('renamed', 'b'), [2, 4]), (pb.equal('renamed', 'missing'), [])] + if evolution == 'rename' else [(pb.is_null('v'), [1, 2, 4]), (pb.equal('v', 'b'), [3])]) + for predicate, expected in cases: + for limit in (None, 1): + builder = table.new_read_builder().with_filter(predicate).with_projection(['k']) + if limit is not None: + builder.with_limit(limit) + actual = builder.new_read().to_arrow(splits, parallelism=1).column('k').to_pylist() + if limit is None: + assert sorted(actual) == expected + else: + assert len(actual) == min(1, len(expected)) + assert all(key in expected for key in actual) From 06abc2ee8cb9e752200e5b2babda36aed372a4d7 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 16 Sep 2026 11:17:37 +0800 Subject: [PATCH 2/3] [python] Expand native planning for REST and resolved FileIO --- paimon-python/README.md | 12 +- paimon-python/pypaimon/common/json_util.py | 2 + paimon-python/pypaimon/read/native_plan.py | 20 ++- .../read/reader/format_pyarrow_reader.py | 3 + paimon-python/pypaimon/read/table_scan.py | 2 +- .../pypaimon/table/file_store_table.py | 5 +- .../tests/native_plan_resolved_schema_test.py | 36 +++-- .../pypaimon/tests/native_plan_rest_test.py | 129 ++++++++++++++++++ .../pypaimon/tests/native_plan_test.py | 16 +++ .../pypaimon/tests/resolving_file_io_test.py | 21 +++ .../pypaimon/tests/snapshot_manager_test.py | 24 ++++ 11 files changed, 250 insertions(+), 20 deletions(-) create mode 100644 paimon-python/pypaimon/tests/native_plan_rest_test.py diff --git a/paimon-python/README.md b/paimon-python/README.md index 91050439d915..b25aca73f3dc 100644 --- a/paimon-python/README.md +++ b/paimon-python/README.md @@ -54,9 +54,15 @@ With Rust main's `Table.from_resolved_schema()` binding, filesystem catalog tables preserve the Python table's resolved schema and complete effective options. Stale table objects, historical schemas, and `copy()` overrides or option removals no longer require catalog reloading or Python planning. -Local tables opened with `FileStoreTable.from_path()` use the same path. -REST tables retain catalog loading for credentials and snapshot resolution; -custom catalog/FileIO contexts still fall back when they cannot be reproduced. +Tables opened with `FileStoreTable.from_path(path, file_io_options=None)` use +the same path with standard local, PyArrow or resolving FileIO. Storage options +configure FileIO; use `copy()` for table read options. +REST tables use `Table.copy_with_resolved_schema()` to preserve the same schema +and option semantics, including branches whose schemas are catalog-managed. +The native table retains REST credentials, token refresh and catalog snapshot +resolution. REST snapshot results (including empty results) take precedence over +filesystem snapshots. REST errors, including HTTP 501, are propagated as in Java. +Custom catalog/FileIO contexts still fall back when they cannot be reproduced. Explicit row ranges on data-evolution tables require `ReadBuilder.with_row_ranges()`. Watermark time travel requires Rust 0.4 or newer. Branch reads require the diff --git a/paimon-python/pypaimon/common/json_util.py b/paimon-python/pypaimon/common/json_util.py index 6effec7d1e09..0ae8c232f95c 100644 --- a/paimon-python/pypaimon/common/json_util.py +++ b/paimon-python/pypaimon/common/json_util.py @@ -148,6 +148,8 @@ def __from_dict(data: Dict[str, Any], target_class: Type[T]) -> T: kwargs[field_name] = ( None if value is None else decoder_mapping[json_name](value) ) + elif value is None: + kwargs[field_name] = None elif json_name in type_mapping: tp = getattr(type_mapping[json_name], '__origin__', None) if tp in (list, List): diff --git a/paimon-python/pypaimon/read/native_plan.py b/paimon-python/pypaimon/read/native_plan.py index c5b36e2c5b93..9d32b0002c66 100644 --- a/paimon-python/pypaimon/read/native_plan.py +++ b/paimon-python/pypaimon/read/native_plan.py @@ -178,8 +178,11 @@ def _resolved_schema_file_io_options(table) -> Optional[dict]: 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) is not LocalFileIO: + 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() @@ -277,8 +280,19 @@ def native_plan( builder = rt.new_read_builder() else: from pypaimon_rust.datafusion import PaimonCatalog - rt = PaimonCatalog(_catalog_options(table)).get_table(table.identifier.get_full_name()) - builder = rt.new_read_builder(_read_options(table)) + catalog = PaimonCatalog(_catalog_options(table)) + if native_method_available('Table', 'copy_with_resolved_schema'): + # 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('%s.%s' % ( + 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()) + builder = rt.new_read_builder() + else: + rt = catalog.get_table(table.identifier.get_full_name()) + builder = rt.new_read_builder(_read_options(table)) if table.current_branch() != 'main': branch = getattr(rt, 'branch', None) if not callable(branch) or branch() != table.current_branch(): diff --git a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py index 7ce7ec1d7a91..33999fec75f8 100644 --- a/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py +++ b/paimon-python/pypaimon/read/reader/format_pyarrow_reader.py @@ -341,6 +341,9 @@ def __init__(self, file_io: FileIO, file_format: str, file_path: str, row_indices: Optional[List[int]] = None, row_ranges: Optional[List[Tuple[int, int]]] = None, row_group_cache: Optional[_DecodedRowGroupCache] = None): + from pypaimon.filesystem.resolving_file_io import ResolvingFileIO + if isinstance(file_io, ResolvingFileIO): + file_io = file_io._get_fileio(file_path) self._predicate_field_names = predicate_field_names or set() file_path_for_pyarrow = file_io.to_filesystem_path(file_path) self._row_group_cache = row_group_cache diff --git a/paimon-python/pypaimon/read/table_scan.py b/paimon-python/pypaimon/read/table_scan.py index de76ce0fd784..186d26116e6c 100755 --- a/paimon-python/pypaimon/read/table_scan.py +++ b/paimon-python/pypaimon/read/table_scan.py @@ -196,7 +196,7 @@ def _native_plan_supported_impl(self) -> bool: from pypaimon.read.native_plan import native_family_search_modes_available if not native_family_search_modes_available(): return False - if not resolved_schema: + if not resolved_schema and not native_method_available('Table', 'copy_with_resolved_schema'): supported_time_travel = any( options.contains_key(key) for key in _NATIVE_TIME_TRAVEL_OPTIONS) # Time travel intentionally carries a historical schema; other stale diff --git a/paimon-python/pypaimon/table/file_store_table.py b/paimon-python/pypaimon/table/file_store_table.py index 3c4a8b1c34aa..c2962236b903 100644 --- a/paimon-python/pypaimon/table/file_store_table.py +++ b/paimon-python/pypaimon/table/file_store_table.py @@ -64,12 +64,13 @@ def __init__(self, file_io: FileIO, identifier: Identifier, table_path: str, file_io, table_path, branch=self.current_branch()) @classmethod - def from_path(cls, table_path: str) -> 'FileStoreTable': + def from_path(cls, table_path: str, file_io_options: Optional[dict] = None) -> 'FileStoreTable': """ Create a FileStoreTable from a table path. This is useful for reading tables created by Java without going through a catalog. + ``file_io_options`` configures storage access; use ``copy`` for table read options. """ - file_io = FileIO.get(table_path, Options({})) + file_io = FileIO.get(table_path, Options(file_io_options or {})) schema_manager = SchemaManager(file_io, table_path) table_schema = schema_manager.latest() diff --git a/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py index 70aea01f5a66..2d6010b49ed7 100644 --- a/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py +++ b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. +from contextlib import ExitStack from unittest.mock import patch import pyarrow as pa @@ -26,24 +27,28 @@ from pypaimon.schema.data_types import AtomicType from pypaimon.schema.schema_change import SchemaChange from pypaimon.table.file_store_table import FileStoreTable +from pypaimon.tests.native_plan_rest_test import rest_catalog # noqa: F401 pytestmark = [pytest.mark.native_plan, pytest.mark.skipif( not native_runtime_available(), reason='Rust planner required')] -@pytest.fixture(params=['append', 'pk', 'de']) +@pytest.fixture(params=[(mode, catalog) for mode in ('append', 'pk', 'de') + for catalog in ('filesystem', 'rest')]) def source(request, tmp_path): - catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + mode, backend = request.param + catalog = (request.getfixturevalue('rest_catalog')[0] if backend == 'rest' + else CatalogFactory.create({'warehouse': str(tmp_path)})) catalog.create_database('default', True) schema = pa.schema([('id', pa.int64()), ('value', pa.string())]) options = {'file.format': 'parquet'} - if request.param == 'de': + if mode == 'de': options.update({'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true'}) - if request.param == 'pk': + if mode == 'pk': options['bucket'] = '1' catalog.create_table('default.t', Schema.from_pyarrow_schema( - schema, options=options, primary_keys=['id'] if request.param == 'pk' else []), False) + schema, options=options, primary_keys=['id'] if mode == 'pk' else []), False) table = catalog.get_table('default.t') _write(table, [{'id': 1, 'value': 'old'}]) return catalog, table @@ -69,9 +74,15 @@ def _read(table, native, predicate=None, projection=None): builder.with_projection(projection) scan = builder.new_scan() if native: - with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')), \ - patch.object(table.schema_manager, 'latest', side_effect=AssertionError('schema reload')), \ - patch('pypaimon_rust.datafusion.PaimonCatalog', side_effect=AssertionError('catalog reload')): + from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader + with ExitStack() as stack: + stack.enter_context(patch.object(scan.file_scanner, 'scan', + side_effect=AssertionError('native fallback'))) + stack.enter_context(patch.object(table.schema_manager, 'latest', + side_effect=AssertionError('schema reload'))) + if type(table.catalog_environment.catalog_loader) is not RESTCatalogLoader: + stack.enter_context(patch('pypaimon_rust.datafusion.PaimonCatalog', + side_effect=AssertionError('catalog reload'))) plan = scan.plan() else: plan = scan.plan() @@ -111,11 +122,14 @@ def test_stale_schema_does_not_confuse_readded_column(source): @pytest.mark.parametrize('uri', [False, True], ids=['path', 'file-uri']) -def test_catalogless_table_uses_resolved_schema(source, uri): +@pytest.mark.parametrize('resolving', [False, True], ids=['local-io', 'resolving-io']) +def test_catalogless_table_uses_resolved_schema(source, uri, resolving): from pathlib import Path + from urllib.parse import unquote, urlparse _, table = source - location = Path(table.table_path).as_uri() if uri else table.table_path - direct = FileStoreTable.from_path(location) + path = unquote(urlparse(table.table_path).path) if table.table_path.startswith('file:') else table.table_path + location = Path(path).as_uri() if uri else path + direct = FileStoreTable.from_path(location, {'resolving-file-io.enabled': str(resolving).lower()}) assert direct.catalog_environment.catalog_loader is None _assert_parity(direct, [{'id': 1, 'value': 'old'}], 1) diff --git a/paimon-python/pypaimon/tests/native_plan_rest_test.py b/paimon-python/pypaimon/tests/native_plan_rest_test.py new file mode 100644 index 000000000000..0c8903896aa7 --- /dev/null +++ b/paimon-python/pypaimon/tests/native_plan_rest_test.py @@ -0,0 +1,129 @@ +# 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. + +from unittest.mock import patch + +import pyarrow as pa +import pytest + +from pypaimon import CatalogFactory, Schema +from pypaimon.api.api_response import ConfigResponse, ErrorResponse, GetTableSnapshotResponse +from pypaimon.api.auth import BearTokenAuthProvider +from pypaimon.read.native_plan import native_runtime_available +from pypaimon.snapshot.table_snapshot import TableSnapshot +from pypaimon.tests.rest.rest_server import RESTCatalogServer + + +pytestmark = [pytest.mark.native_plan, pytest.mark.skipif( + not native_runtime_available(), reason='Rust planner required')] + + +@pytest.fixture +def rest_catalog(tmp_path): + server = RESTCatalogServer(str(tmp_path), BearTokenAuthProvider('test-token'), + ConfigResponse(defaults={'prefix': 'native-test'}), 'warehouse') + server.start() + try: + catalog = CatalogFactory.create({ + 'metastore': 'rest', 'uri': server.get_url(), 'warehouse': 'warehouse', + 'token.provider': 'bear', 'token': 'test-token', 'data-token.enabled': 'false'}) + catalog.create_database('default', True) + yield catalog, server + finally: + server.shutdown() + + +@pytest.fixture +def rest_source(rest_catalog): + from pypaimon.tests.native_plan_resolved_schema_test import _write + catalog, server = rest_catalog + catalog.create_table('default.t', Schema.from_pyarrow_schema( + pa.schema([('id', pa.int64()), ('value', pa.string())])), False) + table = catalog.get_table('default.t') + _write(table, [{'id': 1, 'value': 'old'}]) + first = table.snapshot_manager().get_latest_snapshot() + _write(table, [{'id': 2, 'value': 'new'}]) + return table, server, first + + +@pytest.mark.parametrize('response', ['first', 'empty', 'missing']) +def test_catalog_snapshot_controls_native_plan(rest_source, response): + from pypaimon.tests.native_plan_resolved_schema_test import _assert_parity + table, server, first = rest_source + if response == 'first': + reply, code = GetTableSnapshotResponse(TableSnapshot(first, 1, 0, 1, first.time_millis)), 200 + elif response == 'empty': + reply, code = GetTableSnapshotResponse(), 200 + else: + code = 404 + reply = ErrorResponse('SNAPSHOT', 't', response, code) + with patch.object(server, '_table_snapshot_handle', return_value=server._mock_response(reply, code)): + expected = [{'id': 1, 'value': 'old'}] + snapshot_id = 1 + if response in ('empty', 'missing'): + expected, snapshot_id = [], None + _assert_parity(table, expected, snapshot_id) + + +@pytest.mark.parametrize('code', [403, 404, 500, 501, 503]) +def test_catalog_snapshot_failure_never_reads_disk(rest_source, code): + from pypaimon.read.native_plan import native_plan + table, server, _ = rest_source + reply = ErrorResponse('TABLE', 't', 'snapshot unavailable', code) + with patch.object(server, '_table_snapshot_handle', return_value=server._mock_response(reply, code)): + # Test the binding directly too: a native error must not become a stale + # filesystem plan before TableScan gets a chance to fall back. + with pytest.raises(Exception, match='snapshot unavailable|does not exist|permission'): + native_plan(table) + with pytest.raises(Exception): + table.copy({'scan.native-plan.enabled': 'false'}).new_read_builder().new_scan().plan() + + +@pytest.mark.parametrize('from_tag', [False, True], ids=['empty-branch', 'tagged-branch']) +def test_rest_branch_keeps_catalog_snapshot_and_schema(rest_source, rest_catalog, from_tag): + from pypaimon.common.identifier import Identifier + from pypaimon.tests.native_plan_resolved_schema_test import _assert_parity + table, server, _ = rest_source + catalog, _ = rest_catalog + if from_tag: + catalog.create_tag(table.identifier, 'first', 1) + catalog.create_branch(table.identifier, 'dev', tag_name='first' if from_tag else None) + branch = catalog.get_table(Identifier('default', 't', branch='dev')).copy({'read.batch-size': '1'}) + with patch.object(server, '_table_snapshot_handle', wraps=server._table_snapshot_handle) as load: + _assert_parity(branch, [{'id': 1, 'value': 'old'}] if from_tag else [], 1 if from_tag else None) + assert load.call_count >= 2 + assert all(call.args[2] == 'dev' for call in load.call_args_list) + + +def test_resolved_rest_table_keeps_refreshable_file_io(rest_source, rest_catalog): + from pypaimon.api.api_response import GetTableTokenResponse + from pypaimon.read.native_plan import _resolved_schema_json + from pypaimon_rust.datafusion import PaimonCatalog + table, server, _ = rest_source + catalog, _ = rest_catalog + options = dict(catalog.context.options.to_map(), **{'data-token.enabled': 'true'}) + # Expiry is in the past, so a subsequent data access must refresh; no sleep. + expired = server._mock_response(GetTableTokenResponse(token={}, expires_at_millis=0), 200) + with patch.object(server, '_table_token_handle', return_value=expired): + rt = PaimonCatalog(options).get_table('default.t') + resolved = rt.copy_with_resolved_schema(_resolved_schema_json(table)) + assert resolved.latest_snapshot().id() == 2 + denied = server._mock_response(ErrorResponse('TABLE', 't', 'token denied', 403), 403) + with patch.object(server, '_table_token_handle', return_value=denied) as refresh: + with pytest.raises(Exception, match='token denied'): + resolved.new_read_builder().new_scan().plan() + refresh.assert_called() diff --git a/paimon-python/pypaimon/tests/native_plan_test.py b/paimon-python/pypaimon/tests/native_plan_test.py index 5d6dd8d1d01e..4e48cc1920b4 100644 --- a/paimon-python/pypaimon/tests/native_plan_test.py +++ b/paimon-python/pypaimon/tests/native_plan_test.py @@ -144,6 +144,22 @@ def test_switch_defaults_off(self): self.assertTrue( CoreOptions(Options({"scan.native-plan.enabled": "true"})).native_plan_enabled()) + def test_catalogless_standard_file_io_options_are_preserved(self): + from pypaimon.catalog.catalog_environment import CatalogEnvironment + from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO + from pypaimon.filesystem.resolving_file_io import ResolvingFileIO + from pypaimon.read.native_plan import _resolved_schema_file_io_options + + properties = Options({'s3.path-style-access': True, 's3.endpoint': 'http://localhost:9000'}) + # No storage connection is needed to check the resolved context transfer. + arrow = PyArrowFileIO.__new__(PyArrowFileIO) + arrow.properties = properties + for file_io in (arrow, ResolvingFileIO(properties)): + table = Mock(file_io=file_io, catalog_environment=CatalogEnvironment.empty()) + with patch('pypaimon.read.native_plan.native_method_available', return_value=True): + self.assertEqual(_resolved_schema_file_io_options(table), { + 's3.path-style-access': 'true', 's3.endpoint': 'http://localhost:9000'}) + def test_plan_uses_file_scanner_when_switch_off(self): fs = Mock() sentinel = object() diff --git a/paimon-python/pypaimon/tests/resolving_file_io_test.py b/paimon-python/pypaimon/tests/resolving_file_io_test.py index c3dec4ebe5c5..f73948a0f963 100644 --- a/paimon-python/pypaimon/tests/resolving_file_io_test.py +++ b/paimon-python/pypaimon/tests/resolving_file_io_test.py @@ -65,6 +65,27 @@ def test_cache_key_uses_scheme_and_authority(self): self.assertIsInstance(fio_local, LocalFileIO) self.assertIsInstance(fio_file, LocalFileIO) + def test_pyarrow_reader_resolves_each_file_path(self): + import pyarrow as pa + import pyarrow.parquet as pq + from pathlib import Path + from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader + from pypaimon.schema.data_types import AtomicType, DataField + + with tempfile.TemporaryDirectory() as directory: + resolving = ResolvingFileIO(Options({})) + for value, uri in ((1, False), (2, True)): + path = Path(directory) / ('data-%s.parquet' % value) + pq.write_table(pa.table({'id': [value]}), str(path)) + reader = FormatPyArrowReader( + resolving, 'parquet', path.as_uri() if uri else str(path), + [DataField(0, 'id', AtomicType('BIGINT'))], None) + try: + self.assertEqual(reader.read_arrow_batch().to_pylist(), [{'id': value}]) + self.assertIsNone(reader.read_arrow_batch()) + finally: + reader.close() + def test_is_object_store_with_oss_warehouse(self): opts = Options({CatalogOptions.WAREHOUSE.key(): 'oss://bucket/warehouse'}) resolving = ResolvingFileIO(opts) diff --git a/paimon-python/pypaimon/tests/snapshot_manager_test.py b/paimon-python/pypaimon/tests/snapshot_manager_test.py index f09f61fc3a7e..21dbaa78609c 100644 --- a/paimon-python/pypaimon/tests/snapshot_manager_test.py +++ b/paimon-python/pypaimon/tests/snapshot_manager_test.py @@ -47,6 +47,30 @@ def _build_manager(file_io): class SnapshotManagerTest(unittest.TestCase): """Tests for SnapshotManager batch lookahead methods.""" + def test_rest_snapshot_response_can_be_empty(self): + from pypaimon.api.api_response import GetTableSnapshotResponse + from pypaimon.common.json_util import JSON + for payload in ('{}', '{"snapshot": null}'): + self.assertIsNone(JSON.from_json(payload, GetTableSnapshotResponse).get_snapshot()) + + def test_only_unsupported_rest_snapshot_loader_falls_back(self): + from pypaimon.api.rest_exception import NotImplementedException, RESTException + from pypaimon.snapshot.snapshot_loader import SnapshotLoader + from pypaimon.snapshot.snapshot_manager import SnapshotManager + catalog_loader = Mock() + catalog_loader.load.return_value.load_snapshot.side_effect = NotImplementedError('unsupported') + manager = SnapshotManager(Mock(), '/table', snapshot_loader=SnapshotLoader(catalog_loader, Mock())) + snapshot = _create_mock_snapshot(2) + manager._get_latest_snapshot_from_filesystem = Mock(return_value=snapshot) + self.assertIs(manager.get_latest_snapshot(), snapshot) + manager._get_latest_snapshot_from_filesystem.assert_called_once() + manager._get_latest_snapshot_from_filesystem.reset_mock() + for error in (RESTException('unavailable'), NotImplementedException('unavailable')): + catalog_loader.load.return_value.load_snapshot.side_effect = error + with self.assertRaisesRegex(RuntimeError, 'unavailable'): + manager.get_latest_snapshot() + manager._get_latest_snapshot_from_filesystem.assert_not_called() + def test_find_next_scannable_returns_first_matching(self): """find_next_scannable should return the first snapshot that passes should_scan.""" file_io = Mock() From 0135158781496b7205dba6860450af8a6adcd702 Mon Sep 17 00:00:00 2001 From: JingsongLi Date: Wed, 16 Sep 2026 11:43:57 +0800 Subject: [PATCH 3/3] [python] Expand native planning for JDBC and first-row L0 --- paimon-python/README.md | 14 +++- .../apply_deletion_vector_reader.py | 13 +++- paimon-python/pypaimon/read/native_plan.py | 24 ++++-- paimon-python/pypaimon/read/split_read.py | 8 ++ paimon-python/pypaimon/read/table_scan.py | 14 +++- .../tests/native_plan_materialized_pk_test.py | 65 ++++++++++++++++ .../tests/native_plan_resolved_schema_test.py | 19 ++++- .../pypaimon/tests/native_plan_rest_test.py | 22 ++++++ .../pypaimon/tests/native_plan_test.py | 17 ++-- .../pypaimon/tests/reader_predicate_test.py | 78 +++++++++++++++++++ 10 files changed, 247 insertions(+), 27 deletions(-) diff --git a/paimon-python/README.md b/paimon-python/README.md index b25aca73f3dc..2af01d988c54 100644 --- a/paimon-python/README.md +++ b/paimon-python/README.md @@ -50,17 +50,20 @@ Python planner for unsupported scans. New bindings preserve `plan.snapshot_id` even when pruning removes every split. Native explain output includes snapshot and split metadata; native pruning counters are not exposed. -With Rust main's `Table.from_resolved_schema()` binding, filesystem catalog +With Rust main's `Table.from_resolved_schema()` binding, filesystem and JDBC catalog tables preserve the Python table's resolved schema and complete effective options. Stale table objects, historical schemas, and `copy()` overrides or option removals no longer require catalog reloading or Python planning. Tables opened with `FileStoreTable.from_path(path, file_io_options=None)` use the same path with standard local, PyArrow or resolving FileIO. Storage options configure FileIO; use `copy()` for table read options. +JDBC planning uses the resolved table location and storage properties without +opening another database connection. REST tables use `Table.copy_with_resolved_schema()` to preserve the same schema and option semantics, including branches whose schemas are catalog-managed. The native table retains REST credentials, token refresh and catalog snapshot -resolution. REST snapshot results (including empty results) take precedence over +resolution. Database and table names containing dots are passed as separate +identifier components. REST snapshot results (including empty results) take precedence over filesystem snapshots. REST errors, including HTTP 501, are propagated as in Java. Custom catalog/FileIO contexts still fall back when they cannot be reproduced. @@ -106,7 +109,10 @@ use native planning. With deletion vectors, batch scans exclude level 0 unless `deletion-vectors.merge-on-read=true`, in which case overlapping key ranges stay together when they include L0 and require reader-side merging. Fully materialized DV files across levels use raw splits, including first-row clustering tables. -First-row plans that actually include L0 still fall back to Python. +First-row L0 runs can use native planning, including plans with materialized files +in separate raw splits. Plans that require merging clustered materialized files +still fall back to Python. Readers preserve physical row positions until deletion +vectors are applied, then evaluate residual predicates after merging. Write scans and incremental scans retain level 0. Append and data-evolution chunk shuffle use Rust file and deletion-vector planning. @@ -122,7 +128,7 @@ planning; Python attaches scores to the selected ranges and reads the data. Primary-key sorted indexes refine native batch splits through Python's existing index reader, preserving merge-required splits and the selected snapshot. -Query authorization, batch first-row plans containing L0, +Query authorization, first-row plans mixing L0 with merge-required materialized files, and precomputed primary-key global-index results still use the Python planner. Continuous streaming and write planning also retain their Python entrypoints. Native planning remains optional and is disabled by default. diff --git a/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py b/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py index f491ecfa3f84..237dae37757a 100644 --- a/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py +++ b/paimon-python/pypaimon/deletionvectors/apply_deletion_vector_reader.py @@ -75,6 +75,7 @@ def __init__( self._reader = reader self._deletion_vector = deletion_vector self._returned_position = 0 + self._record_iterator = None def reader(self) -> RecordReader: return self._reader @@ -108,12 +109,18 @@ def read_batch(self) -> Optional[RecordIterator]: Returns: A RecordIterator with deletion filtering, or None if no more data. """ + if self._record_iterator is not None: + self._returned_position = self._record_iterator.returned_position() + 1 batch = self._reader.read_batch() if batch is None: return None - return ApplyDeletionRecordIterator(batch, self._deletion_vector) + # Positions address the whole file, including rows deleted in earlier + # batches. Starting each iterator at zero would apply the DV repeatedly. + self._record_iterator = ApplyDeletionRecordIterator( + batch, self._deletion_vector, self._returned_position) + return self._record_iterator def close(self): self._reader.close() @@ -129,6 +136,7 @@ def __init__( self, iterator: RecordIterator, deletion_vector, + start_position: int = 0, ): """ Initialize an ApplyDeletionRecordIterator. @@ -136,10 +144,11 @@ def __init__( Args: iterator: The underlying record iterator. deletion_vector: The deletion vector to apply for filtering. + start_position: Position of the first record in this batch. """ self._iterator = iterator self._deletion_vector = deletion_vector - self._returned_position = -1 + self._returned_position = start_position - 1 def iterator(self) -> RecordIterator: return self._iterator diff --git a/paimon-python/pypaimon/read/native_plan.py b/paimon-python/pypaimon/read/native_plan.py index 9d32b0002c66..ed12a98948a2 100644 --- a/paimon-python/pypaimon/read/native_plan.py +++ b/paimon-python/pypaimon/read/native_plan.py @@ -114,16 +114,23 @@ def _catalog_options(table) -> dict: 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 } - metastore = _catalog_metastore(loader) - if metastore is None: - raise ValueError("native_plan requires an exact built-in catalog loader") - normalized[CatalogOptions.METASTORE.key()] = metastore if str(getattr(table, 'table_path', '')).startswith('oss://'): from pypaimon.filesystem.jindo_file_system_handler import ( JINDO_AVAILABLE, @@ -187,14 +194,17 @@ def _resolved_schema_file_io_options(table) -> Optional[dict]: return {str(key): _option_value_to_string(value) for key, value in table.file_io.properties.to_map().items() if value is not None} - if _catalog_metastore(loader) != 'filesystem': + 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 - return _catalog_options(table) + # 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: @@ -284,7 +294,7 @@ def native_plan( if native_method_available('Table', 'copy_with_resolved_schema'): # 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('%s.%s' % ( + 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') diff --git a/paimon-python/pypaimon/read/split_read.py b/paimon-python/pypaimon/read/split_read.py index a5bad09a3986..4f1fbd64bfd3 100644 --- a/paimon-python/pypaimon/read/split_read.py +++ b/paimon-python/pypaimon/read/split_read.py @@ -240,6 +240,13 @@ def file_reader_supplier(self, file: DataFileMeta, for_merge_read: bool, read_arrow_predicate, read_paimon_predicate, ) = self._get_fields_and_predicate(file.schema_id, read_fields) + if (file.file_name in self.deletion_file_readers + or (for_merge_read and self.row_ranges is not None)): + # DVs and indexed PK ranges refer to physical file positions. + # Filtering or skipping row groups here would renumber those rows. + # Apply the residual predicate after position selection and merging. + read_arrow_predicate = None + read_paimon_predicate = None # Use external_path if available, otherwise use file_path file_path = file.external_path if file.external_path else file.file_path @@ -904,6 +911,7 @@ def create_reader(self) -> RecordReader: if (self.predicate_for_reader and (self.table.is_primary_key_table or not self._arrow_filter_pushdown_enabled + or self.deletion_file_readers or any(file.schema_id != self.table.table_schema.id for file in self.split.files))): reader = FilterRecordBatchReader( diff --git a/paimon-python/pypaimon/read/table_scan.py b/paimon-python/pypaimon/read/table_scan.py index 186d26116e6c..74a5be1e6bac 100755 --- a/paimon-python/pypaimon/read/table_scan.py +++ b/paimon-python/pypaimon/read/table_scan.py @@ -179,7 +179,10 @@ def _native_plan_supported_impl(self) -> bool: '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: + if not database_name or database_name == UNKNOWN_DATABASE: + return False + if ('.' in database_name + and not native_method_available('Table', 'copy_with_resolved_schema')): return False if self.table.options.query_auth_enabled: return False @@ -289,9 +292,12 @@ def _try_native_plan(self) -> Optional[Plan]: splits = plan.splits() if (self.table.options.merge_engine() == 'first-row' and not fs.skip_level0 and not fs.is_streaming - and any(file.level == 0 for split in splits for file in split.files)): - # Materialized clustered files read raw. Mixing L0 with files - # sorted by clustering columns still needs a reader audit. + and any(file.level == 0 for split in splits for file in split.files) + and any(not split.raw_convertible and any(file.level > 0 for file in split.files) + for split in splits)): + # L0 runs are sorted by PK and can merge using first-row. + # Materialized clustered files must stay in raw splits: their + # physical order need not match the PK merge comparator. return None partition_predicate = self.file_scanner.partition_key_predicate if partition_predicate is not None: diff --git a/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py b/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py index 72399078dc22..6e9471088f57 100644 --- a/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py +++ b/paimon-python/pypaimon/tests/native_plan_materialized_pk_test.py @@ -119,3 +119,68 @@ def test_clustered_materialized_dv_files_use_native_raw_splits(tmp_path, engine, plan = scan.plan() fallback.assert_called_once_with() assert any(file.level == 0 for split in plan.splits() for file in split.files) + + +@pytest.mark.parametrize('target_size', ['1b', '1mb']) +@pytest.mark.parametrize('compacted_partition', [False, True]) +@pytest.mark.parametrize('batch_size', [1, 1024]) +def test_first_row_level_zero_merges_before_filtering(tmp_path, target_size, compacted_partition, batch_size): + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', True) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + pa.schema([('id', pa.int64()), ('value', pa.string()), ('dt', pa.string())]), + primary_keys=['id', 'dt'], partition_keys=['dt'], options={ + 'bucket': '1', 'merge-engine': 'first-row', 'file.format': 'parquet', + 'deletion-vectors.enabled': 'true', 'deletion-vectors.merge-on-read': 'true', + 'pk-clustering-override': 'true', 'clustering.columns': 'value', + 'source.split.target-size': target_size, 'source.split.open-file-cost': '1b', + 'read.batch-size': str(batch_size), + }), False) + table = catalog.get_table('default.t') + batches = [(0, [{'id': 1, 'value': 'first', 'dt': 'pending'}, + {'id': 2, 'value': 'deleted', 'dt': 'pending'}]), + (0, [{'id': 1, 'value': 'later', 'dt': 'pending'}, + {'id': 3, 'value': 'third', 'dt': 'pending'}])] + if compacted_partition: + batches.append((1, [{'id': 4, 'value': 'compacted', 'dt': 'ready'}])) + first_file = None + for level, rows in batches: + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pylist(rows)) + messages = writer.prepare_commit() + for message in messages: + message.new_files = [replace(file, level=level) for file in message.new_files] + if first_file is None: + first_file = message.new_files[0] + commit.commit(messages) + finally: + writer.close() + commit.close() + vector = BitmapDeletionVector() + vector.delete(1) # Remove id=2 from the first sorted L0 run. + entry = TableDeleteByRowId(table)._write_deletion_vector_index( + GenericRow(['pending'], table.partition_keys_fields), 0, {first_file.file_name: vector}) + commit = table.new_batch_write_builder().new_commit() + try: + commit.commit([CommitMessage(partition=('pending',), bucket=0, new_files=[], index_adds=[entry])]) + finally: + commit.close() + pb = table.new_read_builder().new_predicate_builder() + all_ids = [1, 3, 4] if compacted_partition else [1, 3] + for predicate, expected in [(None, all_ids), (pb.equal('value', 'later'), []), + (pb.equal('value', 'first'), [1]), (pb.equal('id', 2), [])]: + for native in (False, True): + builder = table.copy({'scan.native-plan.enabled': str(native).lower()}).new_read_builder() + builder.with_projection(['id']) + if predicate is not None: + builder.with_filter(predicate) + scan = builder.new_scan() + if native: + with patch.object(scan.file_scanner, 'scan', side_effect=AssertionError('native fallback')): + plan = scan.plan() + else: + plan = scan.plan() + assert plan.snapshot_id == len(batches) + 1 + assert sorted(builder.new_read().to_arrow(plan.splits()).column('id').to_pylist()) == expected diff --git a/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py index 2d6010b49ed7..2d837694c8e7 100644 --- a/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py +++ b/paimon-python/pypaimon/tests/native_plan_resolved_schema_test.py @@ -35,11 +35,16 @@ @pytest.fixture(params=[(mode, catalog) for mode in ('append', 'pk', 'de') - for catalog in ('filesystem', 'rest')]) + for catalog in ('filesystem', 'rest', 'jdbc')], ids=lambda case: '-'.join(case)) def source(request, tmp_path): mode, backend = request.param - catalog = (request.getfixturevalue('rest_catalog')[0] if backend == 'rest' - else CatalogFactory.create({'warehouse': str(tmp_path)})) + if backend == 'rest': + catalog = request.getfixturevalue('rest_catalog')[0] + else: + options = {'warehouse': str(tmp_path / 'warehouse')} + if backend == 'jdbc': + options.update({'metastore': 'jdbc', 'uri': 'jdbc:sqlite:' + str(tmp_path / 'catalog.db')}) + catalog = CatalogFactory.create(options) catalog.create_database('default', True) schema = pa.schema([('id', pa.int64()), ('value', pa.string())]) options = {'file.format': 'parquet'} @@ -51,7 +56,9 @@ def source(request, tmp_path): schema, options=options, primary_keys=['id'] if mode == 'pk' else []), False) table = catalog.get_table('default.t') _write(table, [{'id': 1, 'value': 'old'}]) - return catalog, table + yield catalog, table + if backend == 'jdbc': + catalog.close() def _write(table, rows): @@ -74,6 +81,7 @@ def _read(table, native, predicate=None, projection=None): builder.with_projection(projection) scan = builder.new_scan() if native: + from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader from pypaimon.catalog.rest.rest_catalog_loader import RESTCatalogLoader with ExitStack() as stack: stack.enter_context(patch.object(scan.file_scanner, 'scan', @@ -83,6 +91,9 @@ def _read(table, native, predicate=None, projection=None): if type(table.catalog_environment.catalog_loader) is not RESTCatalogLoader: stack.enter_context(patch('pypaimon_rust.datafusion.PaimonCatalog', side_effect=AssertionError('catalog reload'))) + if type(table.catalog_environment.catalog_loader) is JdbcCatalogLoader: + stack.enter_context(patch.object(JdbcCatalogLoader, 'load', + side_effect=AssertionError('JDBC connection during planning'))) plan = scan.plan() else: plan = scan.plan() diff --git a/paimon-python/pypaimon/tests/native_plan_rest_test.py b/paimon-python/pypaimon/tests/native_plan_rest_test.py index 0c8903896aa7..2a34ce4a295f 100644 --- a/paimon-python/pypaimon/tests/native_plan_rest_test.py +++ b/paimon-python/pypaimon/tests/native_plan_rest_test.py @@ -127,3 +127,25 @@ def test_resolved_rest_table_keeps_refreshable_file_io(rest_source, rest_catalog with pytest.raises(Exception, match='token denied'): resolved.new_read_builder().new_scan().plan() refresh.assert_called() + + +@pytest.mark.parametrize('branch', [None, 'dev']) +def test_rest_dotted_database_and_table_keep_identity(rest_catalog, branch): + from pypaimon.common.identifier import Identifier + from pypaimon.tests.native_plan_resolved_schema_test import _assert_parity, _write + catalog, server = rest_catalog + identifier = Identifier('namespace.database', 'table.with.dots') + catalog.create_database(identifier.get_database_name(), False) + catalog.create_table(identifier, Schema.from_pyarrow_schema( + pa.schema([('id', pa.int64()), ('value', pa.string())])), False) + table = catalog.get_table(identifier) + _write(table, [{'id': 1, 'value': 'old'}]) + if branch: + catalog.create_tag(identifier, 'first', 1) + catalog.create_branch(identifier, branch, tag_name='first') + _write(table, [{'id': 2, 'value': 'main'}]) + table = catalog.get_table(Identifier('namespace.database', 'table.with.dots', branch=branch)) + with patch.object(server, '_table_snapshot_handle', wraps=server._table_snapshot_handle) as load: + _assert_parity(table, [{'id': 1, 'value': 'old'}], 1) + assert all((call.args[1].get_database_name(), call.args[1].get_table_name()) + == ('namespace.database', 'table.with.dots') for call in load.call_args_list) diff --git a/paimon-python/pypaimon/tests/native_plan_test.py b/paimon-python/pypaimon/tests/native_plan_test.py index 4e48cc1920b4..b7892c1185db 100644 --- a/paimon-python/pypaimon/tests/native_plan_test.py +++ b/paimon-python/pypaimon/tests/native_plan_test.py @@ -108,6 +108,7 @@ def setUp(self): def test_resolved_schema_keeps_custom_io_and_rest_on_catalog_path(self): from pypaimon.catalog.catalog_environment import CatalogEnvironment + from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader from pypaimon.filesystem.local_file_io import LocalFileIO from pypaimon.read.native_plan import _resolved_schema_file_io_options @@ -120,6 +121,9 @@ class CustomEnvironment(CatalogEnvironment): class CustomLoader(FileSystemCatalogLoader): pass + class CustomJdbcLoader(JdbcCatalogLoader): + pass + table = Mock(file_io=LocalFileIO(), catalog_environment=CatalogEnvironment.empty()) with patch('pypaimon.read.native_plan.native_method_available', return_value=True): self.assertEqual(_resolved_schema_file_io_options(table), {}) @@ -129,15 +133,16 @@ class CustomLoader(FileSystemCatalogLoader): table.catalog_environment = CustomEnvironment() self.assertIsNone(_resolved_schema_file_io_options(table)) table.catalog_environment = CatalogEnvironment.empty() - for loader_type in (RESTCatalogLoader, CustomLoader): + for loader_type in (RESTCatalogLoader, CustomLoader, CustomJdbcLoader): table.catalog_environment.catalog_loader = loader_type( CatalogContext.create_from_options(Options({}))) self.assertIsNone(_resolved_schema_file_io_options(table)) - for attr in ('hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'): - context = CatalogContext.create_from_options(Options({})) - setattr(context, attr, object()) - table.catalog_environment.catalog_loader = FileSystemCatalogLoader(context) - self.assertIsNone(_resolved_schema_file_io_options(table)) + for loader_type in (FileSystemCatalogLoader, JdbcCatalogLoader): + for attr in ('hadoop_conf', 'prefer_io_loader', 'fallback_io_loader'): + context = CatalogContext.create_from_options(Options({})) + setattr(context, attr, object()) + table.catalog_environment.catalog_loader = loader_type(context) + self.assertIsNone(_resolved_schema_file_io_options(table)) def test_switch_defaults_off(self): self.assertFalse(CoreOptions(Options({})).native_plan_enabled()) diff --git a/paimon-python/pypaimon/tests/reader_predicate_test.py b/paimon-python/pypaimon/tests/reader_predicate_test.py index 89e4efef6a8f..accce5555d41 100644 --- a/paimon-python/pypaimon/tests/reader_predicate_test.py +++ b/paimon-python/pypaimon/tests/reader_predicate_test.py @@ -21,6 +21,7 @@ import unittest import pyarrow as pa +import pytest from pypaimon import CatalogFactory from pypaimon import Schema @@ -30,6 +31,83 @@ from pypaimon.schema.data_types import AtomicType, DataField +@pytest.mark.parametrize('mode', ['append', 'pk-raw', 'pk-merge', 'first-row']) +@pytest.mark.parametrize('batch_size', [1, 1024]) +def test_predicate_preserves_deletion_vector_positions(tmp_path, mode, batch_size): + from dataclasses import replace + from pypaimon.deletionvectors.bitmap_deletion_vector import BitmapDeletionVector + from pypaimon.table.row.generic_row import GenericRow + from pypaimon.write.commit_message import CommitMessage + from pypaimon.write.table_delete import TableDeleteByRowId + + catalog = CatalogFactory.create({'warehouse': str(tmp_path)}) + catalog.create_database('default', False) + options = {'file.format': 'parquet', 'deletion-vectors.enabled': 'true', + 'deletion-vectors.merge-on-read': 'true', 'scan.native-plan.enabled': 'false', + 'read.batch-size': str(batch_size)} + if mode != 'append': + options['bucket'] = '1' + if mode == 'first-row': + options.update({'merge-engine': 'first-row', 'pk-clustering-override': 'true', + 'clustering.columns': 'id'}) + catalog.create_table('default.t', Schema.from_pyarrow_schema( + pa.schema([('id', pa.int64())]), options=options, + primary_keys=[] if mode == 'append' else ['id']), False) + table = catalog.get_table('default.t') + builder = table.new_batch_write_builder() + writer, commit = builder.new_write(), builder.new_commit() + try: + writer.write_arrow(pa.Table.from_pydict({'id': [0, 1, 2]})) + messages = writer.prepare_commit() + if mode == 'pk-raw': + for message in messages: + message.new_files = [replace(file, level=1) for file in message.new_files] + commit.commit(messages) + finally: + writer.close() + commit.close() + vector = BitmapDeletionVector() + vector.delete(1) + entry = TableDeleteByRowId(table)._write_deletion_vector_index( + GenericRow([], []), messages[0].bucket, {messages[0].new_files[0].file_name: vector}) + commit = builder.new_commit() + try: + commit.commit([CommitMessage(partition=(), bucket=messages[0].bucket, + new_files=[], index_adds=[entry])]) + finally: + commit.close() + for id_, expected in [(0, [0]), (1, []), (2, [2])]: + read = table.new_read_builder() + read.with_filter(read.new_predicate_builder().equal('id', id_)) + plan = read.new_scan().plan() + if mode in ('pk-merge', 'first-row'): + # A single unique L0 file can be planned raw; exercise the merge + # reader explicitly as overlapping runs and PK indexes do. + for split in plan.splits(): + split.raw_convertible = False + assert read.new_read().to_arrow(plan.splits()).column('id').to_pylist() == expected + if mode != 'append': + from copy import copy + from pypaimon.globalindex.indexed_split import IndexedSplit + from pypaimon.utils.range import Range + + # PK indexes select physical positions in one source file. Exercise + # range filtering both with and without a DV, including a deleted hit. + original = table.new_read_builder().new_scan().plan().splits()[0] + for with_dv in (False, True): + split = copy(original) + split.raw_convertible = False + if not with_dv: + split.data_deletion_files = None + for start, end in ((1, 1), (2, 2), (1, 2)): + indexed = IndexedSplit(split, [Range(start, end)]) + for id_ in (1, 2): + read = table.new_read_builder() + read.with_filter(read.new_predicate_builder().equal('id', id_)) + expected = [id_] if start <= id_ <= end and (not with_dv or id_ != 1) else [] + assert read.new_read().to_arrow([indexed]).column('id').to_pylist() == expected + + class ReaderPredicateTest(unittest.TestCase): @classmethod def setUpClass(cls):