This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 062dc90b70 [python] Keep public APIs independent of execution backends
(#10779)
062dc90b70 is described below
commit 062dc90b70722dec990fb9a86e1cfe750c2afc8a
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 21:43:36 2026 +0800
[python] Keep public APIs independent of execution backends (#10779)
---
docs/docs/pypaimon/data-evolution.md | 6 +
paimon-python/dev/test_packaging.py | 4 +-
paimon-python/pypaimon/catalog/table_query_auth.py | 6 +-
paimon-python/pypaimon/common/transform_schema.py | 244 +++++++++++++++++++++
.../execution/native/write/native_update.py | 8 +-
.../execution/python/casting/data_type_casts.py | 53 -----
.../python/globalindex/create_global_index.py | 2 +-
.../python/globalindex/ray_index_build.py} | 59 +----
.../execution/python/read/predicate_json_parser.py | 79 +------
.../pypaimon/execution/python/read/table_read.py | 3 +-
.../pypaimon/execution/python/write/row_id_plan.py | 22 ++
.../execution/python/write/table_delete.py | 7 +
.../execution/python/write/table_update.py | 2 +-
.../pypaimon/filesystem/format_dispatch.py | 36 +++
.../pypaimon/filesystem/hdfs_native_file_io.py | 12 +-
paimon-python/pypaimon/filesystem/local_file_io.py | 12 +-
.../pypaimon/filesystem/pyarrow_file_io.py | 12 +-
paimon-python/pypaimon/globalindex/__init__.py | 27 ---
.../pypaimon/globalindex/index_dispatch.py | 26 +++
.../pypaimon/multimodal/lerobot/reader.py | 4 +-
paimon-python/pypaimon/multimodal/temporal.py | 29 +--
.../pypaimon/ray/data_evolution_merge_join.py | 32 +--
paimon-python/pypaimon/ray/merge_condition.py | 4 +-
paimon-python/pypaimon/ray/vector_index_build.py | 49 +----
paimon-python/pypaimon/read/read_dispatch.py | 32 +++
paimon-python/pypaimon/schema/data_type_casts.py | 53 ++++-
paimon-python/pypaimon/table/file_store_table.py | 26 +--
.../table/source/batch_vector_search_builder.py | 6 +-
.../table/source/full_text_search_builder.py | 12 +-
.../pypaimon/table/source/search_dispatch.py | 24 +-
.../pypaimon/table/source/vector_search_builder.py | 12 +-
.../tests/global_index_execution_boundary_test.py | 49 +++--
.../pypaimon/tests/lerobot_native_reader_test.py | 9 +-
.../tests/multimodal_temporal_boundary_test.py | 122 +++++++++++
.../tests/native_historical_update_test.py | 6 +-
.../pypaimon/tests/native_ray_row_id_plan_test.py | 55 +++++
paimon-python/pypaimon/tests/native_update_test.py | 9 +-
.../tests/public_execution_boundary_test.py | 113 ++++++++++
.../public_execution_factory_boundary_test.py | 209 ++++++++++++++++++
.../pypaimon/tests/schema_cast_support_test.py | 115 ++++++++++
.../tests/write/row_id_delete_input_test.py | 175 +++++++++++++++
.../pypaimon/utils/file_store_path_factory.py | 4 +-
paimon-python/pypaimon/write/backend_dispatch.py | 76 +++++++
paimon-python/pypaimon/write/row_id_conflict.py | 24 +-
paimon-python/pypaimon/write/row_id_plan.py | 5 +-
paimon-python/pypaimon/write/row_input.py | 26 ++-
paimon-python/pypaimon/write/table_delete.py | 8 +-
paimon-python/pypaimon/write/table_update.py | 11 +-
paimon-python/pypaimon/write/update_dispatch.py | 8 +-
49 files changed, 1459 insertions(+), 468 deletions(-)
diff --git a/docs/docs/pypaimon/data-evolution.md
b/docs/docs/pypaimon/data-evolution.md
index 83b26b5f09..faa94834d3 100644
--- a/docs/docs/pypaimon/data-evolution.md
+++ b/docs/docs/pypaimon/data-evolution.md
@@ -236,6 +236,12 @@ If you already have `_ROW_ID` values, use
`delete_by_row_id` to write deletion
vectors directly. This follow-up deletes the first row from the fresh table
above; use row IDs obtained from your target table in an existing dataset.
+The same method accepts an integer PyArrow `Array` or `ChunkedArray`, so a
+projected `_ROW_ID` column can be submitted without converting it to a Python
+list. NULL IDs and unsigned values outside the signed 64-bit range are
rejected;
+duplicate IDs delete the row once. Native execution retains Arrow buffers and
+chunk boundaries through the Rust binding.
+
```python
write_builder = table.new_batch_write_builder()
table_update = write_builder.new_update()
diff --git a/paimon-python/dev/test_packaging.py
b/paimon-python/dev/test_packaging.py
index 02fe0efdca..ddf4877ec2 100644
--- a/paimon-python/dev/test_packaging.py
+++ b/paimon-python/dev/test_packaging.py
@@ -121,6 +121,8 @@ class PackagingTest(unittest.TestCase):
self.assertFalse(any(name.endswith((".so", ".pyd", ".jar")) for name
in names))
self.assertIn("pypaimon/_version.py", names)
execution_files = (
+ "pypaimon/common/transform_schema.py",
+ "pypaimon/filesystem/format_dispatch.py",
"pypaimon/table/source/search_execution.py",
"pypaimon/ray/search_execution.py",
"pypaimon/execution/native/read/search_execution.py",
@@ -128,6 +130,7 @@ class PackagingTest(unittest.TestCase):
"pypaimon/execution/python/read/search/distributed_vector_search.py",
"pypaimon/execution/python/read/search/distributed_full_text_search.py",
"pypaimon/execution/python/map_blob_key_serializer.py",
+ "pypaimon/execution/python/globalindex/ray_index_build.py",
"pypaimon/execution/python/write/external_path_provider.py",
"pypaimon/execution/python/read/predicate_json_parser.py",
"pypaimon/execution/python/read/blob_view_lookup.py",
@@ -143,7 +146,6 @@ class PackagingTest(unittest.TestCase):
"pypaimon/utils/temporal_string.py",
"pypaimon/utils/row_to_string.py",
"pypaimon/execution/python/casting/__init__.py",
- "pypaimon/execution/python/casting/data_type_casts.py",
"pypaimon/execution/python/casting/schema_evolution.py",
"pypaimon/execution/python/casting/decimal_cast.py",
"pypaimon/execution/python/casting/temporal_cast.py",
diff --git a/paimon-python/pypaimon/catalog/table_query_auth.py
b/paimon-python/pypaimon/catalog/table_query_auth.py
index 091c1f53a4..4a486cb6d0 100644
--- a/paimon-python/pypaimon/catalog/table_query_auth.py
+++ b/paimon-python/pypaimon/catalog/table_query_auth.py
@@ -136,12 +136,12 @@ class TableQueryAuthResult:
def extract_row_filter(self) -> Optional[Callable[[pa.RecordBatch],
pa.Array]]:
if not self.filter:
return None
- from pypaimon.execution.python.read.predicate_json_parser import
parse_predicate_to_batch_filter
+ from pypaimon.read.read_dispatch import create_python_row_filter
if len(self.filter) == 1:
- return parse_predicate_to_batch_filter(self.filter[0])
+ return create_python_row_filter(self.filter[0])
# Java PredicateBuilder.and evaluates entries in their original order.
- return parse_predicate_to_batch_filter(json.dumps({
+ return create_python_row_filter(json.dumps({
'kind': 'COMPOUND', 'function': 'AND',
'children': [json.loads(rule) for rule in self.filter],
}))
diff --git a/paimon-python/pypaimon/common/transform_schema.py
b/paimon-python/pypaimon/common/transform_schema.py
new file mode 100644
index 0000000000..a8a2090da2
--- /dev/null
+++ b/paimon-python/pypaimon/common/transform_schema.py
@@ -0,0 +1,244 @@
+# 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.
+
+"""Infer authorization output schemas from rule metadata without reading
rows."""
+
+import re
+
+import pyarrow as pa
+
+from pypaimon.common.predicate_json_parser import _DATE_EXTRACT_NAMES
+
+
+_INT_MAX = (1 << 31) - 1
+_CHARACTER_TYPE = re.compile(
+ r"\s*(?:STRING(?=\s|\Z)|(?:CHAR|VARCHAR)\s*(?:\(\s*(\d+)\s*\)|(?=\s|\Z)))"
+ r"(?:\s*(?:NOT\s+)?NULL)?\s*\Z", re.IGNORECASE)
+_INTEGER_TYPE = re.compile(
+ r"\s*(?:TINYINT|SMALLINT|INT|INTEGER|BIGINT)(?=\s|\Z)"
+ r"(?:\s*(?:NOT\s+)?NULL)?\s*\Z", re.IGNORECASE)
+
+
+def effective_masked_schema(schema, masking):
+ if not masking:
+ return schema
+ fields = []
+ for field in schema:
+ transform = masking.get(field.name)
+ if transform is None:
+ fields.append(field)
+ else:
+ data_type = _transform_type(transform, schema, field.type)
+ fields.append(pa.field(field.name, data_type, nullable=True,
metadata=field.metadata))
+ return pa.schema(fields, metadata=schema.metadata)
+
+
+def _field_type(reference, schema):
+ name = reference.get('name') if isinstance(reference, dict) else None
+ if not isinstance(name, str) or name not in schema.names:
+ raise ValueError('Column masking refers to a field that is not
present: {!r}'.format(reference))
+ return schema.field(name).type
+
+
+def _string_type(value, schema, stored=False, coerce_literals=False):
+ if value is None or isinstance(value, str):
+ return pa.string()
+ if not isinstance(value, dict):
+ if coerce_literals:
+ return pa.string()
+ raise ValueError('Transform input must be a string or a field:
{!r}'.format(value))
+ if stored:
+ spelling = value.get('type')
+ match = _CHARACTER_TYPE.fullmatch(spelling) if isinstance(spelling,
str) else None
+ if match is None or (match.group(1) is not None and not 1 <=
int(match.group(1)) <= _INT_MAX):
+ raise ValueError('Transform field must be a string:
{!r}'.format(spelling))
+ data_type = _field_type(value, schema)
+ if not (pa.types.is_string(data_type) or
pa.types.is_large_string(data_type)):
+ raise ValueError('Transform field must be a string:
{}'.format(data_type))
+ return data_type
+
+
+def _integer_input(value, schema, stored=True):
+ if value is None or (isinstance(value, int) and not isinstance(value,
bool)):
+ return
+ if not isinstance(value, dict):
+ raise ValueError('Transform position must be an integer or a field:
{!r}'.format(value))
+ spelling = value.get('type')
+ if stored and (not isinstance(spelling, str) or
_INTEGER_TYPE.fullmatch(spelling) is None):
+ raise ValueError('Transform position field must be an integer:
{!r}'.format(spelling))
+ if not pa.types.is_integer(_field_type(value, schema)):
+ raise ValueError('Transform position field must be an integer')
+
+
+def _inputs(transform, minimum, maximum):
+ values = transform.get('inputs')
+ if not isinstance(values, list) or not minimum <= len(values) <= maximum:
+ raise ValueError('{} takes {} to {} inputs'.format(transform['name'],
minimum, maximum))
+ return values
+
+
+def _transform_type(transform, schema, null_type):
+ name = transform['name']
+ if name == 'NULL':
+ return null_type
+ if name == 'FIELD_REF':
+ return _field_type(transform['fieldRef'], schema)
+ if name == 'CAST':
+ _field_type(transform['fieldRef'], schema)
+ _paimon_type_to_arrow(transform['fieldRef']['type'])
+ target = transform['type']
+ width = re.fullmatch(r'\s*(CHAR|VARCHAR|BINARY|VARBINARY)(?=\s|\(|\Z)'
+ r'(?:\s*\(\s*([0-9]+)\s*\))?'
+ r'(?:\s*(?:NOT\s+)?NULL)?\s*', target,
flags=re.IGNORECASE)
+ if width is not None:
+ if not 1 <= int(width.group(2) or 1) <= _INT_MAX:
+ raise ValueError('Cast width must be between 1 and 2147483647')
+ return pa.string() if width.group(1).upper() in ('CHAR',
'VARCHAR') else pa.binary()
+ return _paimon_type_to_arrow(target)
+ if name in _DATE_EXTRACT_NAMES:
+ reference = transform['fieldRef']
+ stored = reference.get('type')
+ match =
re.fullmatch(r'\s*(DATE(?=\s|\Z)|TIMESTAMP(?=\s|\(|\Z)(?:\s*\(\s*([0-9]+)\s*\))?'
+ r'(?:\s*WITHOUT\s+TIME\s+ZONE(?=\s|\Z))?)'
+ r'(?:\s*(?:NOT\s+)?NULL)?\s*', stored,
flags=re.IGNORECASE) \
+ if isinstance(stored, str) else None
+ actual = _field_type(reference, schema)
+ if (match is None or (match.group(2) is not None and
int(match.group(2)) > 9)
+ or not (pa.types.is_date32(actual) or
(pa.types.is_timestamp(actual) and actual.tz is None))):
+ raise ValueError('Calendar transforms require DATE or TIMESTAMP
without time zone')
+ return pa.int32()
+ if name in ('UPPER', 'LOWER', 'LENGTH', 'BIT_LENGTH'):
+ values = _inputs(transform, 1, 1)
+ result = _string_type(values[0], schema, stored=name in ('LENGTH',
'BIT_LENGTH'),
+ coerce_literals=name in ('UPPER', 'LOWER'))
+ return pa.int32() if name in ('LENGTH', 'BIT_LENGTH') else result
+ if name in ('CONCAT', 'CONCAT_WS'):
+ values = _inputs(transform, 0 if name == 'CONCAT' else 1, _INT_MAX)
+ types = [_string_type(value, schema, coerce_literals=True) for value
in values]
+ # Python Arrow's CONCAT also supplies a string separator. A
large-string
+ # argument cannot bind that mixed-width kernel, even for an empty
batch.
+ if name == 'CONCAT' and any(pa.types.is_large_string(data_type) for
data_type in types):
+ raise TypeError('CONCAT inputs must use the same string width as
its separator')
+ return pa.string()
+ if name == 'SUBSTRING':
+ values = _inputs(transform, 2, 3)
+ result = _string_type(values[0], schema)
+ for value in values[1:]:
+ if isinstance(value, str):
+ continue
+ _integer_input(value, schema, stored=False)
+ return result
+ if name == 'TRIM':
+ values = _inputs(transform, 1, 2)
+ if transform.get('trimFlag') not in ('BOTH', 'LEADING', 'TRAILING'):
+ raise ValueError('Unknown trimFlag:
{}'.format(transform.get('trimFlag')))
+ types = [_string_type(value, schema, stored=True) for value in values]
+ return types[0]
+ if name in ('PAD', 'TRANSLATE', 'OVERLAY'):
+ values = _inputs(transform, 3, 4 if name == 'OVERLAY' else 3)
+ _string_type(values[0], schema, stored=True)
+ if name == 'PAD':
+ if transform.get('direction') not in ('LEFT', 'RIGHT'):
+ raise ValueError('PAD direction must be LEFT or RIGHT')
+ _integer_input(values[1], schema)
+ _string_type(values[2], schema, stored=True)
+ elif name == 'TRANSLATE':
+ for value in values[1:]:
+ _string_type(value, schema, stored=True)
+ else:
+ _string_type(values[1], schema, stored=True)
+ for value in values[2:]:
+ _integer_input(value, schema)
+ return pa.string()
+ raise ValueError('Unknown transform type: {}'.format(name))
+
+
+def _paimon_type_to_arrow(paimon_type: str) -> pa.DataType:
+ type_str = paimon_type.strip().upper()
+
+ ltz_match = re.match(
+ r"^TIMESTAMP\s*\((\d+)\)\s+WITH\s+LOCAL\s+TIME\s+ZONE", type_str)
+ if ltz_match:
+ precision = int(ltz_match.group(1))
+ return pa.timestamp(_timestamp_precision_to_unit(precision), tz="UTC")
+
+ m = re.match(r"^([A-Z_ ]+?)(?:\((.+)\))?(?:\s+NOT\s+NULL)?$", type_str)
+ if not m:
+ raise ValueError(f"Cannot parse Paimon type: '{paimon_type}'")
+ base_type = m.group(1).strip()
+ params = m.group(2)
+
+ simple_mapping = {
+ "INT": pa.int32(),
+ "BIGINT": pa.int64(),
+ "SMALLINT": pa.int16(),
+ "TINYINT": pa.int8(),
+ "FLOAT": pa.float32(),
+ "DOUBLE": pa.float64(),
+ "STRING": pa.string(),
+ "BOOLEAN": pa.bool_(),
+ "BYTES": pa.binary(),
+ "DATE": pa.date32(),
+ }
+ if base_type in simple_mapping:
+ return simple_mapping[base_type]
+
+ if base_type in ("VARCHAR", "CHAR"):
+ return pa.string()
+
+ if base_type in ("VARBINARY", "BINARY"):
+ return pa.binary()
+
+ if base_type == "TIMESTAMP":
+ precision = int(params) if params else 6
+ unit = _timestamp_precision_to_unit(precision)
+ return pa.timestamp(unit)
+
+ if base_type in ("TIMESTAMP WITH LOCAL TIME ZONE",
"TIMESTAMP_WITH_LOCAL_TIME_ZONE", "TIMESTAMP_LTZ"):
+ precision = int(params) if params else 6
+ unit = _timestamp_precision_to_unit(precision)
+ return pa.timestamp(unit, tz="UTC")
+
+ if base_type == "TIME":
+ precision = int(params) if params else 6
+ unit = _timestamp_precision_to_unit(precision)
+ return pa.time64(unit) if unit in ("us", "ns") else pa.time32(unit)
+
+ if base_type == "DECIMAL":
+ if params:
+ parts = [x.strip() for x in params.split(",")]
+ if len(parts) == 2:
+ return pa.decimal128(int(parts[0]), int(parts[1]))
+ raise ValueError(f"DECIMAL type requires (precision, scale):
'{paimon_type}'")
+
+ raise ValueError(
+ f"Unsupported Paimon type for PyArrow conversion: '{paimon_type}'. "
+ f"Supported: INT, BIGINT, SMALLINT, TINYINT, FLOAT, DOUBLE, STRING,
VARCHAR, CHAR, "
+ f"BOOLEAN, BYTES, VARBINARY, DATE, TIME(p), TIMESTAMP(p), "
+ f"TIMESTAMP WITH LOCAL TIME ZONE(p), DECIMAL(p,s)."
+ )
+
+
+def _timestamp_precision_to_unit(precision: int) -> str:
+ if precision == 0:
+ return "s"
+ elif precision <= 3:
+ return "ms"
+ elif precision <= 6:
+ return "us"
+ else:
+ return "ns"
diff --git a/paimon-python/pypaimon/execution/native/write/native_update.py
b/paimon-python/pypaimon/execution/native/write/native_update.py
index 7844079a1f..f0455bfa5e 100644
--- a/paimon-python/pypaimon/execution/native/write/native_update.py
+++ b/paimon-python/pypaimon/execution/native/write/native_update.py
@@ -33,7 +33,7 @@ from pypaimon.table.file_store_table import FileStoreTable
from pypaimon.execution.native.write.native_commit import (
create_native_write_table, from_native_commit_messages,
)
-from pypaimon.write.row_input import RowIdUpdateInputMixin
+from pypaimon.write.row_input import RowIdUpdateInputMixin, normalize_row_ids
from pypaimon.write.row_utils import _contains_blob_value, value_for_arrow
@@ -506,11 +506,7 @@ class NativeBatchTableUpdate:
return from_native_commit_messages(self.table, messages)
def delete_by_row_id(self, row_ids):
- ids = []
- for row_id in row_ids:
- if row_id is None:
- raise ValueError('_ROW_ID value must not be null.')
- ids.append(int(row_id))
+ ids = normalize_row_ids(row_ids)
try:
messages = self.writer.delete_by_row_id(ids)
except ValueError as error:
diff --git a/paimon-python/pypaimon/execution/python/casting/data_type_casts.py
b/paimon-python/pypaimon/execution/python/casting/data_type_casts.py
deleted file mode 100644
index ba21cf7db5..0000000000
--- a/paimon-python/pypaimon/execution/python/casting/data_type_casts.py
+++ /dev/null
@@ -1,53 +0,0 @@
-# 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.
-
-"""Probe executable schema-evolution casts using the Python read executor."""
-
-import pyarrow as pa
-
-from pypaimon.schema.data_types import PyarrowFieldParser
-
-
-# Caches the read-time cast probe per (source, target) pyarrow type so the
-# alter-time check stays cheap. Keyed by the pyarrow type strings.
-_EXECUTABLE_CAST_CACHE = {}
-
-
-def schema_evolution_cast_supported(source_type, target_type) -> bool:
- from pypaimon.execution.python.casting.schema_evolution import
cast_array_for_schema_evolution
-
- source_pa = PyarrowFieldParser.from_paimon_type(source_type)
- target_pa = PyarrowFieldParser.from_paimon_type(target_type)
- if source_pa == target_pa:
- return True
- cache_key = (str(source_pa), str(target_pa))
- cached = _EXECUTABLE_CAST_CACHE.get(cache_key)
- if cached is not None:
- return cached
- # Probe a one-row (null-valued) array rather than an empty one. Some Arrow
- # kernels validate target parameters only for a non-empty input. Using the
- # read executor also includes Java's per-value overflow-to-NULL rules, such
- # as integer-to-DECIMAL targets narrower than the source type's full range.
- try:
- cast_array_for_schema_evolution(
- pa.nulls(1, type=source_pa), target_pa, source_type, target_type)
- ok = True
- except (pa.lib.ArrowNotImplementedError, pa.lib.ArrowInvalid,
- pa.lib.ArrowTypeError, ValueError, NotImplementedError):
- ok = False
- _EXECUTABLE_CAST_CACHE[cache_key] = ok
- return ok
diff --git
a/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
b/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
index 026b573c38..804b698ce3 100644
--- a/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/execution/python/globalindex/create_global_index.py
@@ -118,7 +118,7 @@ class PythonGlobalIndexBuilder:
splits, unindexed_ranges, index_field, table_read, index_path,
DataEvolutionIndexSourceMeta(snapshot.id).serialize() if
snapshot else None)
if execution == "ray":
- from pypaimon.ray.vector_index_build import build_vector_index
+ from pypaimon.execution.python.globalindex.ray_index_build import
build_vector_index
return build_vector_index(
self, splits, unindexed_ranges, index_field, table_read,
index_path, concurrency, ray_remote_args)
diff --git a/paimon-python/pypaimon/ray/vector_index_build.py
b/paimon-python/pypaimon/execution/python/globalindex/ray_index_build.py
similarity index 53%
copy from paimon-python/pypaimon/ray/vector_index_build.py
copy to paimon-python/pypaimon/execution/python/globalindex/ray_index_build.py
index fa5f722578..982a648133 100644
--- a/paimon-python/pypaimon/ray/vector_index_build.py
+++ b/paimon-python/pypaimon/execution/python/globalindex/ray_index_build.py
@@ -15,24 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Ray shard construction with one driver-side global-index commit."""
-
-from pypaimon.globalindex.vindex_options import VINDEX_IDENTIFIERS
-from pypaimon.ray.search_execution import execution_options
-
-
-def validate_build_options(builder, concurrency, ray_remote_args):
- if builder._index_type not in VINDEX_IDENTIFIERS:
- raise ValueError("Ray index building supports only native vector
indexes.")
- concurrency, options = execution_options(concurrency, ray_remote_args)
- # Retrying a write task can race with a lost worker writing the same file.
- # A failed build can instead be rerun after its uncommitted outputs close.
- if options.get("max_retries", 0) != 0 or options.get("retry_exceptions",
False):
- raise ValueError("Ray index building requires max_retries=0 and
retry_exceptions=False.")
- options.update(max_retries=0, retry_exceptions=False)
- if builder._core_options.global_index_row_count_per_shard() <= 0:
- raise ValueError("Option 'global-index.row-count-per-shard' must be
greater than 0.")
- return concurrency, options
+"""Schedule the selected Python vector-index builder on Ray workers."""
def build_vector_index(builder, splits, unindexed_ranges, index_field,
table_read,
@@ -79,43 +62,3 @@ def _build_shard(context, shard, file_name):
index_split, index_range = shard
return builder._build_generic_shard(
index_split, index_range, index_field, table_read, index_path,
file_name=file_name)
-
-
-def build_native_vector_index(table, plan, concurrency, remote_args):
- """Schedule immutable Core tasks; all publication remains with the
caller."""
- import ray
-
- shards = plan.shards()
- if not shards:
- return []
- context = ray.put(table)
- remote = ray.remote(_build_native_shard).options(**remote_args)
- pending, results = {}, {}
- next_shard = 0
- try:
- while pending or next_shard < len(shards):
- while len(pending) < concurrency and next_shard < len(shards):
- ref = remote.remote(context, shards[next_shard])
- pending[ref] = next_shard
- next_shard += 1
- ready, _ = ray.wait(list(pending), num_returns=1)
- ref = ready[0]
- ordinal = pending[ref]
- results[ordinal] = ray.get(ref)
- del pending[ref]
- return [message for ordinal in range(len(shards)) for message in
results[ordinal]]
- except BaseException:
- # A worker may have completed even when result delivery failed. Wait
- # for dispatched tasks, but retain every output: its commit ownership
- # is unknown and another attempt may already hold the messages.
- for ref in pending:
- try:
- ray.get(ref)
- except BaseException:
- pass
- raise
-
-
-def _build_native_shard(table, shard):
- from pypaimon.execution.native.write.native_index_build import
build_native_index_shard
- return build_native_index_shard(table, shard)
diff --git
a/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
b/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
index cc7f6a6e04..f3bde3c2e9 100644
--- a/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
+++ b/paimon-python/pypaimon/execution/python/read/predicate_json_parser.py
@@ -25,6 +25,8 @@ from typing import Callable
import pyarrow as pa
import pyarrow.compute as pc
+from pypaimon.common.transform_schema import _paimon_type_to_arrow
+
# utf8_slice_codeunits needs an explicit integer stop on pyarrow 6
_MAX_STOP = 2 ** 31 - 1
@@ -870,80 +872,3 @@ def _convert_literal(literal, target_type: pa.DataType):
import decimal
return pa.scalar(decimal.Decimal(str(literal)), type=target_type)
return literal
-
-
-def _paimon_type_to_arrow(paimon_type: str) -> pa.DataType:
- type_str = paimon_type.strip().upper()
-
- ltz_match = re.match(
- r"^TIMESTAMP\s*\((\d+)\)\s+WITH\s+LOCAL\s+TIME\s+ZONE", type_str)
- if ltz_match:
- precision = int(ltz_match.group(1))
- return pa.timestamp(_timestamp_precision_to_unit(precision), tz="UTC")
-
- m = re.match(r"^([A-Z_ ]+?)(?:\((.+)\))?(?:\s+NOT\s+NULL)?$", type_str)
- if not m:
- raise ValueError(f"Cannot parse Paimon type: '{paimon_type}'")
- base_type = m.group(1).strip()
- params = m.group(2)
-
- simple_mapping = {
- "INT": pa.int32(),
- "BIGINT": pa.int64(),
- "SMALLINT": pa.int16(),
- "TINYINT": pa.int8(),
- "FLOAT": pa.float32(),
- "DOUBLE": pa.float64(),
- "STRING": pa.string(),
- "BOOLEAN": pa.bool_(),
- "BYTES": pa.binary(),
- "DATE": pa.date32(),
- }
- if base_type in simple_mapping:
- return simple_mapping[base_type]
-
- if base_type in ("VARCHAR", "CHAR"):
- return pa.string()
-
- if base_type in ("VARBINARY", "BINARY"):
- return pa.binary()
-
- if base_type == "TIMESTAMP":
- precision = int(params) if params else 6
- unit = _timestamp_precision_to_unit(precision)
- return pa.timestamp(unit)
-
- if base_type in ("TIMESTAMP WITH LOCAL TIME ZONE",
"TIMESTAMP_WITH_LOCAL_TIME_ZONE", "TIMESTAMP_LTZ"):
- precision = int(params) if params else 6
- unit = _timestamp_precision_to_unit(precision)
- return pa.timestamp(unit, tz="UTC")
-
- if base_type == "TIME":
- precision = int(params) if params else 6
- unit = _timestamp_precision_to_unit(precision)
- return pa.time64(unit) if unit in ("us", "ns") else pa.time32(unit)
-
- if base_type == "DECIMAL":
- if params:
- parts = [x.strip() for x in params.split(",")]
- if len(parts) == 2:
- return pa.decimal128(int(parts[0]), int(parts[1]))
- raise ValueError(f"DECIMAL type requires (precision, scale):
'{paimon_type}'")
-
- raise ValueError(
- f"Unsupported Paimon type for PyArrow conversion: '{paimon_type}'. "
- f"Supported: INT, BIGINT, SMALLINT, TINYINT, FLOAT, DOUBLE, STRING,
VARCHAR, CHAR, "
- f"BOOLEAN, BYTES, VARBINARY, DATE, TIME(p), TIMESTAMP(p), "
- f"TIMESTAMP WITH LOCAL TIME ZONE(p), DECIMAL(p,s)."
- )
-
-
-def _timestamp_precision_to_unit(precision: int) -> str:
- if precision == 0:
- return "s"
- elif precision <= 3:
- return "ms"
- elif precision <= 6:
- return "us"
- else:
- return "ns"
diff --git a/paimon-python/pypaimon/execution/python/read/table_read.py
b/paimon-python/pypaimon/execution/python/read/table_read.py
index 858527a735..0127249e44 100644
--- a/paimon-python/pypaimon/execution/python/read/table_read.py
+++ b/paimon-python/pypaimon/execution/python/read/table_read.py
@@ -440,7 +440,8 @@ class PythonTableRead:
defer_blob_resolution,
)
sr._blob_parallelism = blob_parallelism
- sr._parquet_row_group_cache = read._parquet_row_group_cache
+ from pypaimon.read.read_dispatch import python_row_group_cache
+ sr._parquet_row_group_cache =
python_row_group_cache(read._parquet_row_group_cache)
return sr
def _build_split_read(self, split: Split, read_type=None,
diff --git a/paimon-python/pypaimon/execution/python/write/row_id_plan.py
b/paimon-python/pypaimon/execution/python/write/row_id_plan.py
index c364187a94..403afaece3 100644
--- a/paimon-python/pypaimon/execution/python/write/row_id_plan.py
+++ b/paimon-python/pypaimon/execution/python/write/row_id_plan.py
@@ -74,6 +74,28 @@ def row_id_groups(plan):
return sorted(groups, key=lambda group: group[0])
+def validate_live_row_ids(table, info, first_row_id, row_ids):
+ """Reject deleted updates using only the routed snapshot's DV files.
+
+ The writer must still merge physical rows, including tombstones, to
preserve
+ row-id offsets. Filtering the writer's original data would shift those
offsets.
+ """
+ from pypaimon.execution.python.deletionvectors.deletion_vector import
DeletionVector
+
+ split, files = info.first_row_id_index[first_row_id]
+ if split.data_deletion_files is None:
+ return
+ row_ids = row_ids.to_pylist()
+ target_names = {file.file_name for file in files}
+ for file, deletion in zip(split.files, split.data_deletion_files):
+ if deletion is None or file.file_name not in target_names:
+ continue
+ vector = DeletionVector.read(table.file_io, deletion)
+ for row_id in row_ids:
+ if file.row_id_range().contains(row_id) and
vector.is_deleted(row_id - file.first_row_id):
+ raise ValueError("Cannot update deleted _ROW_ID
{}.".format(row_id))
+
+
class PythonRowIdPlan:
"""Route captured metadata with no file access or mutation state."""
diff --git a/paimon-python/pypaimon/execution/python/write/table_delete.py
b/paimon-python/pypaimon/execution/python/write/table_delete.py
index 1db2ad4276..40ba071e80 100644
--- a/paimon-python/pypaimon/execution/python/write/table_delete.py
+++ b/paimon-python/pypaimon/execution/python/write/table_delete.py
@@ -20,6 +20,8 @@ from bisect import bisect_right
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Set, Tuple
+import pyarrow as pa
+
from pypaimon.execution.python.deletionvectors.bitmap_deletion_vector import
BitmapDeletionVector
from pypaimon.execution.python.deletionvectors.deletion_vector import
DeletionVector
from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
@@ -114,6 +116,11 @@ class PythonTableDeleteByRowId:
@staticmethod
def _deduplicate_row_ids(row_ids: Sequence[int]) -> List[int]:
+ if isinstance(row_ids, (pa.Array, pa.ChunkedArray)):
+ from pypaimon.write.row_input import normalize_row_ids
+ # Python's bitmap writer needs Python integers. Native retains
+ # the Arrow buffers through the SDK and never enters this path.
+ row_ids = normalize_row_ids(row_ids).to_pylist()
result = []
seen = set()
for row_id in row_ids:
diff --git a/paimon-python/pypaimon/execution/python/write/table_update.py
b/paimon-python/pypaimon/execution/python/write/table_update.py
index ce54a83348..b881983128 100644
--- a/paimon-python/pypaimon/execution/python/write/table_update.py
+++ b/paimon-python/pypaimon/execution/python/write/table_update.py
@@ -487,7 +487,7 @@ class PythonTableUpdate:
def _delete_rows_with_python(self, row_ids):
context = self._context
- return
TableDeleteByRowId._from_python(context.table).delete(list(row_ids))
+ return TableDeleteByRowId._from_python(context.table).delete(row_ids)
class _ShardBatchIterator:
diff --git a/paimon-python/pypaimon/filesystem/format_dispatch.py
b/paimon-python/pypaimon/filesystem/format_dispatch.py
new file mode 100644
index 0000000000..47111de1fd
--- /dev/null
+++ b/paimon-python/pypaimon/filesystem/format_dispatch.py
@@ -0,0 +1,36 @@
+# 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.
+
+"""Construct format helpers for explicitly selected Python FileIO methods.
+
+Native readers and writers use Core FileIO and never enter these adapters.
+"""
+
+
+def new_row_writer(output_stream, fields, zstd_level=1):
+ from pypaimon.execution.python.write.writer.format_row_writer import
FormatRowWriter
+ return FormatRowWriter(output_stream, fields, zstd_level=zstd_level)
+
+
+def new_blob_writer(output_stream):
+ from pypaimon.execution.python.write.blob_format_writer import
BlobFormatWriter
+ return BlobFormatWriter(output_stream)
+
+
+def vortex_location(file_io, path):
+ from pypaimon.execution.python.read.reader.vortex_utils import
to_vortex_specified
+ return to_vortex_specified(file_io, path)
diff --git a/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
b/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
index 9bbd58dbc4..43db6ef266 100644
--- a/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
+++ b/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
@@ -619,13 +619,13 @@ class HdfsNativeFileIO(FileIO):
def write_row(self, path: str, data: pyarrow.Table, fields=None,
zstd_level: int = 1, **kwargs):
try:
- from pypaimon.execution.python.write.writer.format_row_writer
import FormatRowWriter
+ from pypaimon.filesystem.format_dispatch import new_row_writer
if fields is None:
fields = PyarrowFieldParser.to_paimon_schema(data.schema)
with self.new_output_stream(path) as output_stream:
- writer = FormatRowWriter(output_stream, fields,
zstd_level=zstd_level)
+ writer = new_row_writer(output_stream, fields,
zstd_level=zstd_level)
writer.write_table(data)
writer.close()
except Exception as e:
@@ -633,7 +633,7 @@ class HdfsNativeFileIO(FileIO):
raise RuntimeError(f"Failed to write row file {path}: {e}") from e
def write_blob(self, path: str, data: pyarrow.Table, **kwargs):
- from pypaimon.execution.python.write.blob_format_writer import
BlobFormatWriter
+ from pypaimon.filesystem.format_dispatch import new_blob_writer
try:
if data.num_columns != 1:
@@ -651,7 +651,7 @@ class HdfsNativeFileIO(FileIO):
num_rows = data.num_rows
field_name = fields[0].name
with self.new_output_stream(path) as output_stream:
- writer = BlobFormatWriter(output_stream)
+ writer = new_blob_writer(output_stream)
for i in range(num_rows):
writer.write_value(records_dict[field_name][i],
fields, self.uri_reader_factory)
@@ -674,8 +674,8 @@ class HdfsNativeFileIO(FileIO):
import vortex
from vortex import store
- from pypaimon.execution.python.read.reader.vortex_utils import
(to_vortex_specified)
- file_path_for_vortex, store_kwargs = to_vortex_specified(self,
path)
+ from pypaimon.filesystem.format_dispatch import vortex_location
+ file_path_for_vortex, store_kwargs = vortex_location(self, path)
if store_kwargs:
vortex_store = store.from_url(file_path_for_vortex,
**store_kwargs)
diff --git a/paimon-python/pypaimon/filesystem/local_file_io.py
b/paimon-python/pypaimon/filesystem/local_file_io.py
index 7512ad0a3b..eb2c902950 100644
--- a/paimon-python/pypaimon/filesystem/local_file_io.py
+++ b/paimon-python/pypaimon/filesystem/local_file_io.py
@@ -427,8 +427,8 @@ class LocalFileIO(FileIO):
from vortex._lib.io import write as vortex_write
os.makedirs(os.path.dirname(path), exist_ok=True)
- from pypaimon.execution.python.read.reader.vortex_utils import
(to_vortex_specified)
- _, store_kwargs = to_vortex_specified(self, path)
+ from pypaimon.filesystem.format_dispatch import vortex_location
+ _, store_kwargs = vortex_location(self, path)
if store_kwargs:
from vortex import store
@@ -442,7 +442,7 @@ class LocalFileIO(FileIO):
def write_row(self, path: str, data: pyarrow.Table, fields=None,
zstd_level: int = 1, **kwargs):
try:
- from pypaimon.execution.python.write.writer.format_row_writer
import FormatRowWriter
+ from pypaimon.filesystem.format_dispatch import new_row_writer
from pypaimon.schema.data_types import PyarrowFieldParser
if fields is None:
@@ -454,7 +454,7 @@ class LocalFileIO(FileIO):
parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'wb') as output_stream:
- writer = FormatRowWriter(output_stream, fields,
zstd_level=zstd_level)
+ writer = new_row_writer(output_stream, fields,
zstd_level=zstd_level)
writer.write_table(data)
writer.close()
except Exception as e:
@@ -462,7 +462,7 @@ class LocalFileIO(FileIO):
raise RuntimeError(f"Failed to write row file {path}: {e}") from e
def write_blob(self, path: str, data: pyarrow.Table, **kwargs):
- from pypaimon.execution.python.write.blob_format_writer import
BlobFormatWriter
+ from pypaimon.filesystem.format_dispatch import new_blob_writer
try:
if data.num_columns != 1:
@@ -485,7 +485,7 @@ class LocalFileIO(FileIO):
parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'wb') as output_stream:
- writer = BlobFormatWriter(output_stream)
+ writer = new_blob_writer(output_stream)
for i in range(num_rows):
writer.write_value(records_dict[field_name][i], fields,
self.uri_reader_factory)
writer.close()
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 252d86841d..259281063b 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -1003,8 +1003,8 @@ class PyArrowFileIO(FileIO):
import vortex
from vortex import store
- from pypaimon.execution.python.read.reader.vortex_utils import
(to_vortex_specified)
- file_path_for_vortex, store_kwargs = to_vortex_specified(self,
path)
+ from pypaimon.filesystem.format_dispatch import vortex_location
+ file_path_for_vortex, store_kwargs = vortex_location(self, path)
if store_kwargs:
vortex_store = store.from_url(file_path_for_vortex,
**store_kwargs)
@@ -1018,13 +1018,13 @@ class PyArrowFileIO(FileIO):
def write_row(self, path: str, data: pyarrow.Table, fields=None,
zstd_level: int = 1, **kwargs):
try:
- from pypaimon.execution.python.write.writer.format_row_writer
import FormatRowWriter
+ from pypaimon.filesystem.format_dispatch import new_row_writer
if fields is None:
fields = PyarrowFieldParser.to_paimon_schema(data.schema)
with self.new_output_stream(path) as output_stream:
- writer = FormatRowWriter(output_stream, fields,
zstd_level=zstd_level)
+ writer = new_row_writer(output_stream, fields,
zstd_level=zstd_level)
writer.write_table(data)
writer.close()
except Exception as e:
@@ -1032,7 +1032,7 @@ class PyArrowFileIO(FileIO):
raise RuntimeError(f"Failed to write row file {path}: {e}") from e
def write_blob(self, path: str, data: pyarrow.Table, **kwargs):
- from pypaimon.execution.python.write.blob_format_writer import
BlobFormatWriter
+ from pypaimon.filesystem.format_dispatch import new_blob_writer
try:
if data.num_columns != 1:
@@ -1047,7 +1047,7 @@ class PyArrowFileIO(FileIO):
num_rows = data.num_rows
field_name = fields[0].name
with self.new_output_stream(path) as output_stream:
- writer = BlobFormatWriter(output_stream)
+ writer = new_blob_writer(output_stream)
for i in range(num_rows):
writer.write_value(records_dict[field_name][i], fields,
self.uri_reader_factory)
writer.close()
diff --git a/paimon-python/pypaimon/globalindex/__init__.py
b/paimon-python/pypaimon/globalindex/__init__.py
index 880b389e33..4840b9269b 100644
--- a/paimon-python/pypaimon/globalindex/__init__.py
+++ b/paimon-python/pypaimon/globalindex/__init__.py
@@ -30,15 +30,6 @@ if sys.version_info[:2] < (3, 7):
ScoreGetter,
)
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta,
GlobalIndexIOMeta
- from pypaimon.execution.python.globalindex.global_index_evaluator import
GlobalIndexEvaluator
- from
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner
import (
- DataEvolutionGlobalIndexScanner,
- )
- from pypaimon.execution.python.globalindex.key_serializer import
KeySerializer
- from pypaimon.execution.python.globalindex.memory_slice_input import
MemorySliceInput
- from pypaimon.execution.python.globalindex.offset_global_index_reader
import OffsetGlobalIndexReader
- from pypaimon.execution.python.globalindex.sorted_file_global_index_reader
import SortedFileGlobalIndexReader
- from pypaimon.execution.python.globalindex.sorted_file_meta_selector
import SortedFileMetaSelector
from pypaimon.globalindex.sorted_index_file_meta import SortedIndexFileMeta
from pypaimon.globalindex.create_global_index import (
GlobalIndexBuilder,
@@ -61,13 +52,6 @@ __all__ = [
'ScoreGetter',
'GlobalIndexMeta',
'GlobalIndexIOMeta',
- 'GlobalIndexEvaluator',
- 'DataEvolutionGlobalIndexScanner',
- 'KeySerializer',
- 'MemorySliceInput',
- 'OffsetGlobalIndexReader',
- 'SortedFileGlobalIndexReader',
- 'SortedFileMetaSelector',
'SortedIndexFileMeta',
'GlobalIndexBuilder',
'create_global_index',
@@ -87,17 +71,6 @@ _MODULE_BY_EXPORT = {
'ScoreGetter': 'pypaimon.globalindex.vector_search_result',
'GlobalIndexMeta': 'pypaimon.globalindex.global_index_meta',
'GlobalIndexIOMeta': 'pypaimon.globalindex.global_index_meta',
- 'GlobalIndexEvaluator':
'pypaimon.execution.python.globalindex.global_index_evaluator',
- 'DataEvolutionGlobalIndexScanner':
-
'pypaimon.execution.python.globalindex.data_evolution_global_index_scanner',
- 'KeySerializer': 'pypaimon.execution.python.globalindex.key_serializer',
- 'MemorySliceInput':
'pypaimon.execution.python.globalindex.memory_slice_input',
- 'OffsetGlobalIndexReader':
- 'pypaimon.execution.python.globalindex.offset_global_index_reader',
- 'SortedFileGlobalIndexReader':
-
'pypaimon.execution.python.globalindex.sorted_file_global_index_reader',
- 'SortedFileMetaSelector':
- 'pypaimon.execution.python.globalindex.sorted_file_meta_selector',
'SortedIndexFileMeta': 'pypaimon.globalindex.sorted_index_file_meta',
'GlobalIndexBuilder': 'pypaimon.globalindex.create_global_index',
'create_global_index': 'pypaimon.globalindex.create_global_index',
diff --git a/paimon-python/pypaimon/globalindex/index_dispatch.py
b/paimon-python/pypaimon/globalindex/index_dispatch.py
index 39c18df03b..821a238bea 100644
--- a/paimon-python/pypaimon/globalindex/index_dispatch.py
+++ b/paimon-python/pypaimon/globalindex/index_dispatch.py
@@ -104,3 +104,29 @@ def build_global_index_drop(request, partition_filter):
return messages
from pypaimon.execution.python.globalindex.drop_global_index import
build_global_index_drop as execute
return execute(request, partition_filter)
+
+
+def build_python_vector_index(builder, splits, unindexed_ranges, index_field,
table_read,
+ index_path, concurrency, remote_args):
+ from pypaimon.execution.python.globalindex.ray_index_build import
build_vector_index
+ return build_vector_index(
+ builder, splits, unindexed_ranges, index_field, table_read,
+ index_path, concurrency, remote_args)
+
+
+def build_python_vector_index_shard(context, shard, file_name):
+ from pypaimon.execution.python.globalindex.ray_index_build import
_build_shard
+ return _build_shard(context, shard, file_name)
+
+
+def build_native_vector_index_shard(table, shard):
+ from pypaimon.execution.native.write.native_index_build import
build_native_index_shard
+ return build_native_index_shard(table, shard)
+
+
+def create_python_scalar_index_scanner(table, *, predicate, snapshot):
+ """Open the scalar scanner after Python lookup execution was selected."""
+ from
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner
import (
+ DataEvolutionGlobalIndexScanner,
+ )
+ return DataEvolutionGlobalIndexScanner.create(table, predicate=predicate,
snapshot=snapshot)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/reader.py
b/paimon-python/pypaimon/multimodal/lerobot/reader.py
index f85269095d..ee636b2aeb 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/reader.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/reader.py
@@ -154,8 +154,8 @@ class _FrameLocator:
def _index_scanner(self, predicate):
if not self._scanner_initialized:
- from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
- self._scanner = DataEvolutionGlobalIndexScanner.create(
+ from pypaimon.globalindex.index_dispatch import
create_python_scalar_index_scanner
+ self._scanner = create_python_scalar_index_scanner(
self._table,
predicate=predicate,
snapshot=self._snapshot,
diff --git a/paimon-python/pypaimon/multimodal/temporal.py
b/paimon-python/pypaimon/multimodal/temporal.py
index 3852ee46e9..ddd84e124d 100644
--- a/paimon-python/pypaimon/multimodal/temporal.py
+++ b/paimon-python/pypaimon/multimodal/temporal.py
@@ -29,16 +29,15 @@ import pyarrow.compute as pc
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.common.options.core_options import CoreOptions, StartupMode
from pypaimon.common.predicate_json_parser import
_collect_all_field_refs_from_transform
-from pypaimon.execution.python.read.predicate_json_parser import
_apply_predicate_transform
+from pypaimon.common.transform_schema import effective_masked_schema as
_effective_masked_schema
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.multimodal.query import ScanQuery
from pypaimon.read.query_auth_split import QueryAuthSplit, resolve_auth_result
-from pypaimon.execution.python.read.reader.format_pyarrow_reader import
(_DecodedRowGroupCache)
+from pypaimon.read.read_dispatch import _ParquetRowGroupCacheRequest
from pypaimon.read.read_support import _ClosableArrowBatchReader
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.table.special_fields import SpecialFields
-from pypaimon.execution.python.read.search.global_index_live_row_filter import
table_at_snapshot
from pypaimon.utils.range import Range
@@ -202,7 +201,7 @@ class TemporalAlignment:
anchor_metadata = _metadata_batches(
self._anchor, self._on, self._by, batch_size)
- row_group_cache = _DecodedRowGroupCache(
+ row_group_cache = _ParquetRowGroupCacheRequest(
_TEMPORAL_ROW_GROUP_CACHE_MAX_SIZE)
anchor_fetcher = _RowIdFetcher(self._anchor, row_group_cache)
source_fetchers = []
@@ -964,7 +963,7 @@ def _pin_scan_to_snapshot(query):
else None
)
if snapshot is not None and tag_name is None:
- table = table_at_snapshot(table, snapshot)
+ table = table._copy_with_snapshot(snapshot)
pinned = ScanQuery(table)
pinned._predicate = query._predicate
pinned._projection = query._projection
@@ -1459,26 +1458,6 @@ def _mask_dependencies(masking, targets, table_schema):
]
-def _effective_masked_schema(schema, masking):
- if not masking:
- return schema
- batch = pa.RecordBatch.from_arrays([
- pa.array([], type=field.type) for field in schema
- ], schema=schema)
- fields = []
- for field in schema:
- transform = masking.get(field.name)
- if transform is None:
- fields.append(field)
- continue
- masked = _apply_predicate_transform(
- transform, batch, null_type=field.type)
- fields.append(pa.field(
- field.name, masked.type, nullable=True,
- metadata=field.metadata))
- return pa.schema(fields, metadata=schema.metadata)
-
-
def _project_effective_schema(
schema, name_paths, effective_schema, masking):
if not masking:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 5544353b96..543ab968ed 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -716,10 +716,9 @@ def distributed_update_apply(
)
update = captured_table.new_batch_write_builder().new_update()
worker = update.new_update_by_row_id(_precomputed_files_info=info)
- from pypaimon.write.backend_dispatch import
native_row_id_update_selected
- if not native_row_id_update_selected(worker):
- _validate_live_row_ids(
- captured_table, info, int(group[frid_col][0].as_py()),
for_update[row_id_name].to_pylist())
+ from pypaimon.write.backend_dispatch import validate_row_id_update_rows
+ validate_row_id_update_rows(
+ worker, info, int(group[frid_col][0].as_py()),
for_update[row_id_name])
msgs = worker.update_columns(for_update, list(captured_cols))
return pa.Table.from_pydict({
"msgs_blob": [pickle.dumps(msgs)],
@@ -900,7 +899,7 @@ def distributed_delete_apply(
"merging."
)
- row_ids = group.column(row_id_name).to_pylist()
+ row_ids = group.column(row_id_name)
worker = TableDeleteByRowId(
captured_table,
_precomputed_anchor_ranges=ray.get(precomputed_info_ref),
@@ -910,7 +909,7 @@ def distributed_delete_apply(
"msgs_blob": pa.array([pickle.dumps(msgs)], type=pa.binary()),
"n_deleted": pa.array([len(row_ids)], type=pa.int64()),
"row_ids_blob": pa.array(
- [pickle.dumps(row_ids if collect_row_ids else [])],
+ [pickle.dumps(row_ids.to_pylist() if collect_row_ids else [])],
type=pa.binary(),
),
})
@@ -1023,24 +1022,3 @@ def distributed_write_collect_msgs(
write_kwargs["concurrency"] = concurrency
insert_ds.write_datasink(sink, **write_kwargs)
return sink.collected
-
-
-def _validate_live_row_ids(table, info, first_row_id, row_ids):
- """Reject deleted updates using only the routed snapshot's DV files.
-
- The writer must still merge physical rows, including tombstones, to
preserve
- row-id offsets. Filtering the writer's original data would shift those
offsets.
- """
- from pypaimon.execution.python.deletionvectors.deletion_vector import
DeletionVector
-
- split, files = info.first_row_id_index[first_row_id]
- if split.data_deletion_files is None:
- return
- target_names = {file.file_name for file in files}
- for file, deletion in zip(split.files, split.data_deletion_files):
- if deletion is None or file.file_name not in target_names:
- continue
- vector = DeletionVector.read(table.file_io, deletion)
- for row_id in row_ids:
- if file.row_id_range().contains(row_id) and
vector.is_deleted(row_id - file.first_row_id):
- raise ValueError("Cannot update deleted _ROW_ID
{}.".format(row_id))
diff --git a/paimon-python/pypaimon/ray/merge_condition.py
b/paimon-python/pypaimon/ray/merge_condition.py
index a918488e92..7afcda4c7a 100644
--- a/paimon-python/pypaimon/ray/merge_condition.py
+++ b/paimon-python/pypaimon/ray/merge_condition.py
@@ -51,14 +51,14 @@ def remap_source_on_keys(
def filter_batch(
batch: pa.Table, condition: str, _pre_rewritten: bool = False,
) -> pa.Table:
- from pypaimon.execution.python.write.merge_condition import filter_batch
as execute
+ from pypaimon.write.backend_dispatch import filter_merge_condition_batch
as execute
return execute(batch, condition, _pre_rewritten)
def apply_condition(
batch: pa.Table, rewritten: str, empty_schema: pa.Schema,
) -> pa.Table:
- from pypaimon.execution.python.write.merge_condition import
apply_condition as execute
+ from pypaimon.write.backend_dispatch import apply_merge_condition as
execute
return execute(batch, rewritten, empty_schema)
diff --git a/paimon-python/pypaimon/ray/vector_index_build.py
b/paimon-python/pypaimon/ray/vector_index_build.py
index fa5f722578..b0afbe6704 100644
--- a/paimon-python/pypaimon/ray/vector_index_build.py
+++ b/paimon-python/pypaimon/ray/vector_index_build.py
@@ -37,48 +37,15 @@ def validate_build_options(builder, concurrency,
ray_remote_args):
def build_vector_index(builder, splits, unindexed_ranges, index_field,
table_read,
index_path, concurrency, remote_args):
- import ray
- from pypaimon.execution.python.globalindex.build_plan import
split_by_global_index_shard
- from pypaimon.execution.python.globalindex.index_file_utils import
new_global_index_file_name
-
- shards = split_by_global_index_shard(
- splits, builder._core_options.global_index_row_count_per_shard(),
unindexed_ranges)
- # Allocate names before dispatch, so failed/lost result delivery cannot
hide
- # files from cleanup. Workers never publish index manifests or snapshots.
- names = [new_global_index_file_name("vindex-" + builder._index_type) for _
in shards]
- context = ray.put((builder, index_field, table_read, index_path))
- remote = ray.remote(_build_shard).options(**remote_args)
- pending = {}
- results = {}
- next_shard = 0
- try:
- while pending or next_shard < len(shards):
- while len(pending) < concurrency and next_shard < len(shards):
- ref = remote.remote(context, shards[next_shard],
names[next_shard])
- pending[ref] = next_shard
- next_shard += 1
- ready, _ = ray.wait(list(pending), num_returns=1)
- ref = ready[0]
- results[pending.pop(ref)] = ray.get(ref)
- return [results[i] for i in range(len(shards)) if results[i] is not
None]
- except BaseException:
- # Do not delete a file while another worker may still be writing it.
- # Stop submissions and drain every dispatched task before cleanup.
- for ref in pending:
- try:
- ray.get(ref)
- except BaseException:
- pass
- for name in names:
- builder._table.file_io.delete_quietly(index_path.rstrip("/") + "/"
+ name)
- raise
+ from pypaimon.globalindex.index_dispatch import build_python_vector_index
+ return build_python_vector_index(
+ builder, splits, unindexed_ranges, index_field, table_read,
+ index_path, concurrency, remote_args)
def _build_shard(context, shard, file_name):
- builder, index_field, table_read, index_path = context
- index_split, index_range = shard
- return builder._build_generic_shard(
- index_split, index_range, index_field, table_read, index_path,
file_name=file_name)
+ from pypaimon.globalindex.index_dispatch import
build_python_vector_index_shard
+ return build_python_vector_index_shard(context, shard, file_name)
def build_native_vector_index(table, plan, concurrency, remote_args):
@@ -117,5 +84,5 @@ def build_native_vector_index(table, plan, concurrency,
remote_args):
def _build_native_shard(table, shard):
- from pypaimon.execution.native.write.native_index_build import
build_native_index_shard
- return build_native_index_shard(table, shard)
+ from pypaimon.globalindex.index_dispatch import
build_native_vector_index_shard
+ return build_native_vector_index_shard(table, shard)
diff --git a/paimon-python/pypaimon/read/read_dispatch.py
b/paimon-python/pypaimon/read/read_dispatch.py
index 31fd58ff9a..2e2867fedb 100644
--- a/paimon-python/pypaimon/read/read_dispatch.py
+++ b/paimon-python/pypaimon/read/read_dispatch.py
@@ -18,6 +18,7 @@
"""Preparation selection for reading and planning; execution is never
replayed."""
import logging
+import threading
from typing import Iterator, List, Optional, Tuple
import pyarrow
@@ -28,6 +29,37 @@ from pypaimon.read.split import Split
from pypaimon.read.scan_stats import ScanStats
+class _ParquetRowGroupCacheRequest:
+ """Share cache configuration until a Python reader needs decoded rows."""
+
+ def __init__(self, max_size):
+ self.max_size = max_size
+ self._cache = None
+ self._lock = threading.Lock()
+
+ def __getstate__(self):
+ # Decoded batches and locks belong to the process that reads them.
+ return {'max_size': self.max_size}
+
+ def __setstate__(self, state):
+ self.__init__(state['max_size'])
+
+
+def python_row_group_cache(request):
+ if not isinstance(request, _ParquetRowGroupCacheRequest):
+ return request
+ with request._lock:
+ if request._cache is None:
+ from pypaimon.execution.python.read.reader.format_pyarrow_reader
import _DecodedRowGroupCache
+ request._cache = _DecodedRowGroupCache(request.max_size)
+ return request._cache
+
+
+def create_python_row_filter(rule_json):
+ from pypaimon.execution.python.read.predicate_json_parser import
parse_predicate_to_batch_filter
+ return parse_predicate_to_batch_filter(rule_json)
+
+
def validate_read_backend(read):
"""Validate the explicitly selected Python backend without loading
Native."""
if not read.table.options.native_read_enabled():
diff --git a/paimon-python/pypaimon/schema/data_type_casts.py
b/paimon-python/pypaimon/schema/data_type_casts.py
index 83067563fb..69a9314438 100644
--- a/paimon-python/pypaimon/schema/data_type_casts.py
+++ b/paimon-python/pypaimon/schema/data_type_casts.py
@@ -27,8 +27,8 @@ applies the conversion leniently.
"""
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataTypeParser,
- MapType, MultisetType, RowType,
- VectorType)
+ MapType, MultisetType,
PyarrowFieldParser,
+ RowType, VectorType)
# ---- Type roots
--------------------------------------------------------------
@@ -152,6 +152,38 @@ def _build_rules():
_IMPLICIT_RULES, _EXPLICIT_RULES = _build_rules()
+# Materialization capabilities are schema contracts, independent of which
+# executor is installed or selected. Keep the existing Arrow leaf conversions
+# and the Java conversions supplied by the readers here, without executing a
+# cast while validating a schema change. Logical admissibility is checked by
+# supports_cast separately; Arrow also has storage casts absent from that spec.
+_NUMERIC_CAST_TARGETS = CHARACTER_STRING | NUMERIC | {BOOLEAN}
+_TEXT_CAST_TARGETS = CHARACTER_STRING | BINARY_STRING | NUMERIC | {
+ BOOLEAN, DATE, BLOB,
+} | TIMESTAMP_FAMILY
+_BINARY_CAST_TARGETS = CHARACTER_STRING | BINARY_STRING | NUMERIC | {BOOLEAN,
BLOB}
+_TIMESTAMP_CAST_TARGETS = CHARACTER_STRING | TIMESTAMP_FAMILY | {BIGINT, DATE,
TIME}
+_EXECUTABLE_CAST_RULES = {
+ CHAR: _TEXT_CAST_TARGETS,
+ VARCHAR: _TEXT_CAST_TARGETS,
+ BINARY: _BINARY_CAST_TARGETS,
+ VARBINARY: _BINARY_CAST_TARGETS,
+ BLOB: _BINARY_CAST_TARGETS,
+ BOOLEAN: CHARACTER_STRING | INTEGER_NUMERIC | {BOOLEAN, FLOAT, DOUBLE},
+ DECIMAL: CHARACTER_STRING | NUMERIC,
+ TINYINT: _NUMERIC_CAST_TARGETS,
+ SMALLINT: _NUMERIC_CAST_TARGETS,
+ INTEGER: _NUMERIC_CAST_TARGETS | {DATE, TIME, TIMESTAMP},
+ BIGINT: _NUMERIC_CAST_TARGETS | TIMESTAMP_FAMILY,
+ FLOAT: _NUMERIC_CAST_TARGETS,
+ DOUBLE: _NUMERIC_CAST_TARGETS,
+ DATE: CHARACTER_STRING | TIMESTAMP_FAMILY | {DATE, INTEGER},
+ TIME: CHARACTER_STRING | {TIME, INTEGER},
+ TIMESTAMP: _TIMESTAMP_CAST_TARGETS,
+ TIMESTAMP_LTZ: _TIMESTAMP_CAST_TARGETS,
+ VARIANT: {VARIANT},
+}
+
def supports_cast(source_type, target_type, allow_explicit: bool = True) ->
bool:
"""Whether ``source_type`` can be cast to ``target_type`` for a column type
@@ -190,7 +222,7 @@ def _equals_ignore_nullable(source_type, target_type) ->
bool:
def can_execute_cast(source_type, target_type) -> bool:
- """Whether the Python read path can actually *materialize* a stored
+ """Whether the read path can actually *materialize* a stored
``source_type`` value as ``target_type`` when reading a file written before
the column type change.
@@ -222,7 +254,14 @@ def can_execute_cast(source_type, target_type) -> bool:
# Any other conversion touching a constructed type has no runtime cast.
if source_root in CONSTRUCTED or target_root in CONSTRUCTED:
return False
- # Leaf-to-leaf: probe the same executor used by old-file readers, including
- # Java conversions with no direct PyArrow kernel, such as INT -> TIMESTAMP.
- from pypaimon.execution.python.casting.data_type_casts import
schema_evolution_cast_supported
- return schema_evolution_cast_supported(source_type, target_type)
+ # Resolve Arrow type metadata to retain parameter validation and alternate
+ # LTZ spellings. No arrays, cast kernels or execution backends are needed.
+ source_pa = PyarrowFieldParser.from_paimon_type(source_type)
+ target_pa = PyarrowFieldParser.from_paimon_type(target_type)
+ if source_pa == target_pa:
+ return True
+ if source_root in TIMESTAMP_FAMILY:
+ source_root = TIMESTAMP_LTZ if source_pa.tz is not None else TIMESTAMP
+ if target_root in TIMESTAMP_FAMILY:
+ target_root = TIMESTAMP_LTZ if target_pa.tz is not None else TIMESTAMP
+ return target_root in _EXECUTABLE_CAST_RULES.get(source_root, set())
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 49324a28df..40e0dcb816 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -489,30 +489,8 @@ class FileStoreTable(Table):
def create_row_key_extractor(
self, ignore_existing: bool = False
) -> 'RowKeyExtractor':
- from pypaimon.execution.python.write.row_key_extractor import (
- DynamicBucketRowKeyExtractor, FixedBucketRowKeyExtractor,
- PostponeBucketRowKeyExtractor, UnawareBucketRowKeyExtractor)
-
- bucket_mode = self.bucket_mode()
- if bucket_mode == BucketMode.HASH_FIXED:
- return FixedBucketRowKeyExtractor(self.table_schema)
- elif bucket_mode == BucketMode.BUCKET_UNAWARE:
- return UnawareBucketRowKeyExtractor(self.table_schema)
- elif bucket_mode == BucketMode.POSTPONE_MODE:
- return PostponeBucketRowKeyExtractor(self.table_schema)
- elif bucket_mode == BucketMode.HASH_DYNAMIC:
- return DynamicBucketRowKeyExtractor(
- self.table_schema,
- table=self,
- ignore_existing=ignore_existing,
- )
- elif bucket_mode == BucketMode.CROSS_PARTITION:
- raise ValueError(
- "CROSS_PARTITION primary-key writes require a persistent "
- "global primary-key index, which PyPaimon does not yet support"
- )
- else:
- raise ValueError(f"Unsupported bucket mode: {bucket_mode}")
+ from pypaimon.write.backend_dispatch import create_row_key_extractor
+ return create_row_key_extractor(self, ignore_existing)
def copy(self, options: dict) -> 'FileStoreTable':
return self._copy(options, resolve_time_travel=True)
diff --git a/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
index 91ca0e4e56..fecb09d4fa 100644
--- a/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
+++ b/paimon-python/pypaimon/table/source/batch_vector_search_builder.py
@@ -127,8 +127,4 @@ class
BatchVectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl,
execution = copy(self)
execution._options = dict(self._options)
execution._query_vectors = [copy(vector) for vector in
self._query_vectors]
- return create_vector_read(execution,
execution._new_python_batch_vector_read, batch=True)
-
- def _new_python_batch_vector_read(self):
- from pypaimon.execution.python.read.search.factories import
new_batch_vector_read
- return new_batch_vector_read(self)
+ return create_vector_read(execution, batch=True)
diff --git a/paimon-python/pypaimon/table/source/full_text_search_builder.py
b/paimon-python/pypaimon/table/source/full_text_search_builder.py
index 0c3b5fa370..21bfbed2be 100644
--- a/paimon-python/pypaimon/table/source/full_text_search_builder.py
+++ b/paimon-python/pypaimon/table/source/full_text_search_builder.py
@@ -146,11 +146,7 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
from pypaimon.table.source.search_dispatch import create_full_text_scan
reject_search_under_query_auth(self._table)
self._primary_key_full_text_definition()
- return create_full_text_scan(self,
copy(self)._new_python_full_text_scan)
-
- def _new_python_full_text_scan(self):
- from pypaimon.execution.python.read.search.factories import
new_full_text_scan
- return new_full_text_scan(self)
+ return create_full_text_scan(self, copy(self))
def new_full_text_read(self) -> FullTextRead:
from pypaimon.table.source.search_dispatch import create_full_text_read
@@ -170,11 +166,7 @@ class FullTextSearchBuilderImpl(FullTextSearchBuilder):
"Primary-key full-text search only supports the FAST "
"full-text index search mode; FULL and DETAIL require "
"merge-aware logical-row fallback.")
- return create_full_text_read(self,
copy(self)._new_python_full_text_read)
-
- def _new_python_full_text_read(self):
- from pypaimon.execution.python.read.search.factories import
new_full_text_read
- return new_full_text_read(self)
+ return create_full_text_read(self, copy(self))
def _text_columns(self):
if self._query is None:
diff --git a/paimon-python/pypaimon/table/source/search_dispatch.py
b/paimon-python/pypaimon/table/source/search_dispatch.py
index 166d073b83..963d96ab18 100644
--- a/paimon-python/pypaimon/table/source/search_dispatch.py
+++ b/paimon-python/pypaimon/table/source/search_dispatch.py
@@ -77,7 +77,11 @@ def _native_requested(builder):
return callable(enabled) and enabled()
-def create_vector_scan(builder, python_factory, batch=False):
+def create_vector_scan(builder, execution, batch=False):
+ def python_factory():
+ from pypaimon.execution.python.read.search.factories import
new_vector_scan
+ return new_vector_scan(execution)
+
if not _native_requested(builder):
return python_factory()
from pypaimon.execution.native.read.native_search_plan import
native_vector_scan
@@ -85,7 +89,11 @@ def create_vector_scan(builder, python_factory, batch=False):
return python_factory() if native is None else _SearchScan(native,
python_factory)
-def create_vector_read(builder, python_factory, batch=False):
+def create_vector_read(builder, batch=False):
+ def python_factory():
+ from pypaimon.execution.python.read.search.factories import
new_batch_vector_read, new_vector_read
+ return new_batch_vector_read(builder) if batch else
new_vector_read(builder)
+
if not _native_requested(builder):
return python_factory()
from pypaimon.execution.native.read.native_search_plan import
native_vector_read
@@ -96,7 +104,11 @@ def create_vector_read(builder, python_factory,
batch=False):
return reader_type(native, python_factory)
-def create_full_text_scan(builder, python_factory):
+def create_full_text_scan(builder, execution):
+ def python_factory():
+ from pypaimon.execution.python.read.search.factories import
new_full_text_scan
+ return new_full_text_scan(execution)
+
if not _native_requested(builder):
return python_factory()
from pypaimon.execution.native.read.native_search_plan import
native_full_text_scan
@@ -104,7 +116,11 @@ def create_full_text_scan(builder, python_factory):
return python_factory() if native is None else _SearchScan(native,
python_factory)
-def create_full_text_read(builder, python_factory):
+def create_full_text_read(builder, execution):
+ def python_factory():
+ from pypaimon.execution.python.read.search.factories import
new_full_text_read
+ return new_full_text_read(execution)
+
if not _native_requested(builder):
return python_factory()
from pypaimon.execution.native.read.native_search_plan import
native_full_text_read
diff --git a/paimon-python/pypaimon/table/source/vector_search_builder.py
b/paimon-python/pypaimon/table/source/vector_search_builder.py
index 7b9a32d9af..f4dc16c201 100644
--- a/paimon-python/pypaimon/table/source/vector_search_builder.py
+++ b/paimon-python/pypaimon/table/source/vector_search_builder.py
@@ -223,11 +223,7 @@ class AbstractVectorSearchBuilderImpl:
from pypaimon.table.source.search_dispatch import create_vector_scan
execution = copy(self)
execution._options = dict(self._options)
- return create_vector_scan(self, execution._new_python_vector_scan,
self._native_batch_search)
-
- def _new_python_vector_scan(self):
- from pypaimon.execution.python.read.search.factories import
new_vector_scan
- return new_vector_scan(self)
+ return create_vector_scan(self, execution, self._native_batch_search)
def _is_primary_key_vector_search(self):
if self._vector_column is None:
@@ -265,8 +261,4 @@ class
VectorSearchBuilderImpl(AbstractVectorSearchBuilderImpl, VectorSearchBuild
execution = copy(self)
execution._options = dict(self._options)
execution._query_vector = copy(self._query_vector)
- return create_vector_read(execution, execution._new_python_vector_read)
-
- def _new_python_vector_read(self):
- from pypaimon.execution.python.read.search.factories import
new_vector_read
- return new_vector_read(self)
+ return create_vector_read(execution)
diff --git
a/paimon-python/pypaimon/tests/global_index_execution_boundary_test.py
b/paimon-python/pypaimon/tests/global_index_execution_boundary_test.py
index b23894e626..06b3ba44d2 100644
--- a/paimon-python/pypaimon/tests/global_index_execution_boundary_test.py
+++ b/paimon-python/pypaimon/tests/global_index_execution_boundary_test.py
@@ -17,9 +17,7 @@
"""Shared index contracts and Native builds do not load Python index
executors."""
-import importlib
from pathlib import Path
-import pickle
import subprocess
import sys
import textwrap
@@ -208,17 +206,36 @@ assert not any(name == 'ray' or
name.startswith('pypaimon.ray') for name in sys.
assert result.returncode == 0, result.stdout + result.stderr
[email protected]('public_module,new_module,name', [
- ('globalindex',
- 'execution.python.globalindex.global_index_evaluator',
'GlobalIndexEvaluator'),
- ('globalindex',
- 'execution.python.globalindex.data_evolution_global_index_scanner',
'DataEvolutionGlobalIndexScanner'),
- ('globalindex', 'execution.python.globalindex.key_serializer',
'KeySerializer'),
- ('globalindex', 'execution.python.globalindex.memory_slice_input',
'MemorySliceInput'),
-])
-def test_public_index_exports_resolve_to_canonical_class(public_module,
new_module, name):
- public_module, new_module = 'pypaimon.' + public_module, 'pypaimon.' +
new_module
- canonical = getattr(importlib.import_module(new_module), name)
- assert getattr(importlib.import_module(public_module), name) is canonical
- public_pickle = ('c%s\n%s\n.' % (public_module, name)).encode('ascii')
- assert pickle.loads(public_pickle) is canonical
[email protected](sys.version_info < (3, 7), reason='Python 3.6 retains
eager package exports')
+def test_public_index_exports_are_backend_independent_contracts():
+ script = r"""
+import importlib.abc
+import pickle
+import sys
+
+class RejectExecution(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == 'pypaimon.execution' or
fullname.startswith('pypaimon.execution.'):
+ raise AssertionError('Public index export imported execution: ' +
fullname)
+
+sys.meta_path.insert(0, RejectExecution())
+import pypaimon.globalindex as indexes
+
+for name in indexes.__all__:
+ exported = getattr(indexes, name)
+ # Existing public import paths still resolve in transported references.
+ reference = ('cpypaimon.globalindex\n%s\n.' % name).encode('ascii')
+ assert pickle.loads(reference) is exported
+for name in ('GlobalIndexEvaluator', 'DataEvolutionGlobalIndexScanner',
+ 'KeySerializer', 'MemorySliceInput', 'OffsetGlobalIndexReader',
+ 'SortedFileGlobalIndexReader', 'SortedFileMetaSelector'):
+ assert name not in indexes.__all__ and not hasattr(indexes, name), name
+assert not any(name == 'pypaimon.execution' or
name.startswith('pypaimon.execution.')
+ for name in sys.modules)
+"""
+ result = subprocess.run(
+ [sys.executable, '-c', textwrap.dedent(script)],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
diff --git a/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
b/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
index c8f7a3ad17..7b7612bc75 100644
--- a/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
+++ b/paimon-python/pypaimon/tests/lerobot_native_reader_test.py
@@ -140,7 +140,8 @@ def
test_missing_native_runtime_keeps_cached_python_lookup(frames):
@pytest.mark.skipif(not native_runtime_available(), reason="native reader
required")
def test_native_plan_failure_reuses_scanner_and_keeps_predicate(frames):
- from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+ from
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner
import (
+ DataEvolutionGlobalIndexScanner)
frames.add(pa.table({"index": [40, 50], "value": [4, 5]}))
current = reader(frames, **{
"scan.native-plan.enabled": "true", "read.native.enabled": "false",
@@ -199,7 +200,8 @@ def test_auto_native_uses_private_table_copy(frames,
available):
def test_unsupported_native_plan_reuses_python_index_scanner(frames):
- from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+ from
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner
import (
+ DataEvolutionGlobalIndexScanner)
frames.add(pa.table({"index": [40], "value": [4]}))
with patch("pypaimon.execution.native.runtime.native_runtime_available",
return_value=True):
current = reader(frames)
@@ -260,7 +262,8 @@ def
test_native_lookup_preserves_query_authorization(frames, denied):
@pytest.mark.parametrize("option", ["scan.native-plan.enabled",
"read.native.enabled"])
def test_pickled_native_locator_rebuilds_pinned_fallback_once(frames, option):
- from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+ from
pypaimon.execution.python.globalindex.data_evolution_global_index_scanner
import (
+ DataEvolutionGlobalIndexScanner)
frames.add(pa.table({"index": [40], "value": [4]}))
current = reader(frames, **{
"scan.native-plan.enabled": "false", "read.native.enabled": "false",
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
b/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
new file mode 100644
index 0000000000..4dc94cec86
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_boundary_test.py
@@ -0,0 +1,122 @@
+# 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.
+
+"""Temporal schema and cache preparation keep reader execution independent."""
+
+import pickle
+import subprocess
+import sys
+
+import pyarrow as pa
+import pytest
+
+
+def test_importing_multimodal_does_not_load_python_execution():
+ result = subprocess.run([sys.executable, '-B', '-c', '''
+import sys
+import pypaimon.multimodal
+loaded = [name for name in sys.modules if
name.startswith('pypaimon.execution.python.')]
+assert not loaded, loaded
+'''], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+
+
+def test_selected_native_batches_do_not_initialize_the_python_cache():
+ result = subprocess.run([sys.executable, '-B', '-c', '''
+import sys
+from types import SimpleNamespace
+import pyarrow as pa
+from pypaimon.read.read_dispatch import _ParquetRowGroupCacheRequest, to_arrow
+schema = pa.schema([('value', pa.int32())])
+batch = pa.record_batch([pa.array([7], type=pa.int32())], schema=schema)
+request = _ParquetRowGroupCacheRequest(1024)
+read = SimpleNamespace(
+ include_row_kind=False,
+ _parquet_row_group_cache=request,
+ _output_arrow_schema=lambda splits: schema,
+ _resolve_parallelism=lambda value, count: 1,
+ _resolve_blob_parallelism=lambda value: 1,
+ _try_native_batches=lambda *args, **kwargs: [batch],
+ _batches_to_arrow=lambda batches, output_schema:
pa.Table.from_batches(batches, schema=output_schema))
+assert to_arrow(read, [object()]).to_pydict() == {'value': [7]}
+assert request._cache is None
+loaded = [name for name in sys.modules if
name.startswith('pypaimon.execution.python.')]
+assert not loaded, loaded
+'''], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+
+
[email protected]('data_type,transform', [
+ (pa.int32(), {'name': 'NULL'}),
+ (pa.int32(), {'name': 'FIELD_REF', 'fieldRef': {'name': 'value'}}),
+ (pa.int32(), {'name': 'CAST', 'fieldRef': {'name': 'value', 'type':
'INT'}, 'type': 'STRING'}),
+ (pa.string(), {'name': 'UPPER', 'inputs': [{'name': 'value', 'type':
'STRING'}]}),
+ (pa.string(), {'name': 'UPPER', 'inputs': [42]}),
+ (pa.large_string(), {'name': 'LOWER', 'inputs': [{'name': 'value', 'type':
'STRING'}]}),
+ (pa.string(), {'name': 'CONCAT', 'inputs': [{'name': 'value'}, 'tail']}),
+ (pa.large_string(), {'name': 'CONCAT_WS', 'inputs': ['-', {'name':
'value'}]}),
+ (pa.large_string(), {'name': 'SUBSTRING', 'inputs': [{'name': 'value'}, 1,
2]}),
+ (pa.large_string(), {'name': 'SUBSTRING', 'inputs': [{'name': 'value'},
-1, 2]}),
+ (pa.large_string(), {'name': 'TRIM', 'trimFlag': 'BOTH', 'inputs':
[{'name': 'value', 'type': 'STRING'}]}),
+ (pa.large_string(), {'name': 'LENGTH', 'inputs': [{'name': 'value',
'type': 'STRING'}]}),
+ (pa.large_string(), {'name': 'BIT_LENGTH', 'inputs': [{'name': 'value',
'type': 'STRING'}]}),
+ (pa.large_string(), {'name': 'PAD', 'direction': 'LEFT', 'inputs':
[{'name': 'value', 'type': 'STRING'}, 4, 'x']}),
+ (pa.large_string(), {'name': 'TRANSLATE', 'inputs': [{'name': 'value',
'type': 'STRING'}, 'a', 'b']}),
+ (pa.large_string(), {'name': 'OVERLAY', 'inputs': [{'name': 'value',
'type': 'STRING'}, 'x', 1]}),
+ (pa.date32(), {'name': 'YEAR', 'fieldRef': {'name': 'value', 'type':
'DATE'}}),
+])
+def test_temporal_mask_schema_matches_actual_python_mask(data_type, transform):
+ from pypaimon.common.transform_schema import effective_masked_schema
+ from pypaimon.execution.python.read.predicate_json_parser import
_apply_predicate_transform
+
+ schema = pa.schema([pa.field('value', data_type, nullable=False,
metadata={b'field': b'value'})],
+ metadata={b'schema': b'value'})
+ batch = pa.RecordBatch.from_arrays([pa.array([], type=data_type)],
schema=schema)
+ actual = _apply_predicate_transform(transform, batch, null_type=data_type)
+ inferred = effective_masked_schema(schema, {'value': transform})
+ assert inferred.field('value').type == actual.type
+ assert inferred.field('value').nullable
+ assert inferred.field('value').metadata == schema.field('value').metadata
+ assert inferred.metadata == schema.metadata
+
+
[email protected]('transform', [
+ {'name': 'unknown'},
+ {'name': 'FIELD_REF', 'fieldRef': {'name': 'missing'}},
+ {'name': 'CAST', 'fieldRef': {'name': 'value', 'type': 'STRING'}, 'type':
'INVALID'},
+ {'name': 'SUBSTRING', 'inputs': ['value']},
+ {'name': 'TRIM', 'trimFlag': 'INVALID', 'inputs': ['value']},
+ {'name': 'PAD', 'direction': 'INVALID', 'inputs': ['value', 4, 'x']},
+ {'name': 'LENGTH', 'inputs': [{'name': 'value', 'type': 'INT'}]},
+])
+def test_temporal_mask_schema_rejects_invalid_rules(transform):
+ from pypaimon.common.transform_schema import effective_masked_schema
+
+ with pytest.raises((ValueError, KeyError, TypeError)):
+ effective_masked_schema(pa.schema([('value', pa.string())]), {'value':
transform})
+
+
+def
test_temporal_cache_is_created_only_for_python_and_recreated_after_transport():
+ from pypaimon.read.read_dispatch import _ParquetRowGroupCacheRequest,
python_row_group_cache
+
+ request = _ParquetRowGroupCacheRequest(1024)
+ assert request._cache is None
+ cached = python_row_group_cache(request)
+ assert python_row_group_cache(request) is cached
+ restored = pickle.loads(pickle.dumps(request))
+ assert restored._cache is None
+ assert python_row_group_cache(restored) is not cached
diff --git a/paimon-python/pypaimon/tests/native_historical_update_test.py
b/paimon-python/pypaimon/tests/native_historical_update_test.py
index 7bf9426591..020b235e8b 100644
--- a/paimon-python/pypaimon/tests/native_historical_update_test.py
+++ b/paimon-python/pypaimon/tests/native_historical_update_test.py
@@ -30,7 +30,8 @@ from pypaimon.schema.schema_change import SchemaChange
from pypaimon.table.data_evolution_merge_into import WhenMatched,
WhenNotMatched, source_col
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.execution.native.write.native_update import
NativeShardTableUpdator
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.execution.python.write.table_update_by_row_id import
PythonTableUpdateByRowId
+from pypaimon.write.backend_dispatch import native_row_id_update_selected
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
@@ -91,7 +92,7 @@ def _update(table, operation, stream, native):
update = builder.new_update().with_update_type(['value'])
kwargs = dict(commit_identifier=37) if stream else {}
changed = pa.table({'_ROW_ID': [0], 'value': [11]})
- guard = patch.object(TableUpdateByRowId, 'update_columns',
+ guard = patch.object(PythonTableUpdateByRowId, 'update_columns',
side_effect=AssertionError('Python historical update
fallback')) if native else ExitStack()
with guard:
if operation == 'row_id':
@@ -100,6 +101,7 @@ def _update(table, operation, stream, native):
return update.update_by_arrow_batches_with_row_id(iter([changed]),
**kwargs)
if operation == 'incremental':
updater = update.new_update_by_row_id(**kwargs)
+ assert native_row_id_update_selected(updater) == native
return updater.update_columns(changed, ['value'])
if operation == 'predicate':
predicate =
table.new_read_builder().new_predicate_builder().equal('id', 1)
diff --git a/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
b/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
index 4072c17a6b..76543fa786 100644
--- a/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_ray_row_id_plan_test.py
@@ -186,6 +186,9 @@ def
test_ray_callbacks_route_read_update_and_delete_with_captured_core_plans(tmp
if native:
guards.enter_context(patch.object(PythonRowIdPlan, '_positions',
side_effect=AssertionError('Python row-ID routing')))
+ guards.enter_context(patch(
+
'pypaimon.execution.python.write.row_id_plan.validate_live_row_ids',
+ side_effect=AssertionError('Python deletion-vector
validation')))
source = _dataset(pa.table({'_ROW_ID': pa.array([3, 0, 3],
type=pa.int64())}))
read = distributed_read_by_row_id(source, table, ['id', 'value'],
num_partitions=2)
rows = pa.concat_tables(list(read.iter_batches())).sort_by('id')
@@ -214,3 +217,55 @@ def
test_ray_callbacks_route_read_update_and_delete_with_captured_core_plans(tmp
read = distributed_read_by_row_id(source, table, ['id', 'value'],
num_partitions=2)
rows = pa.concat_tables(list(read.iter_batches())).sort_by('id')
assert rows.to_pydict() == {'id': [1, 4], 'value': [8, 9], '_ROW_ID':
[0, 3]}
+
+
[email protected]('native', _ENGINES)
[email protected]('collect_row_ids', [False, True])
[email protected]('selected_ids', [[], [0, 3]])
+def test_ray_delete_transports_arrow_ids_and_collects_only_requested_actions(
+ tmp_path, native, collect_row_ids, selected_ids, monkeypatch):
+ from pypaimon.ray.data_evolution_merge_join import distributed_delete_apply
+
+ table = _table(tmp_path)
+ _append(table, [1, 2])
+ _append(table, [3, 4])
+ table = table.copy({'write.native.enabled': str(native).lower()})
+ snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+ scheduler = SimpleNamespace(
+ put=lambda value: pickle.loads(pickle.dumps(value)), get=lambda value:
value)
+ monkeypatch.setitem(sys.modules, 'ray', scheduler)
+ source = _dataset(pa.table({'_ROW_ID': pa.array(selected_ids,
type=pa.int64())}))
+ delete = TableDeleteByRowId.delete
+ received = []
+
+ def delete_arrow(worker, row_ids):
+ assert isinstance(row_ids, (pa.Array, pa.ChunkedArray))
+ assert row_ids.type == pa.int64()
+ received.append(row_ids)
+ return delete(worker, row_ids)
+
+ with patch.object(TableDeleteByRowId, 'delete', delete_arrow):
+ messages, count, actions = distributed_delete_apply(
+ source, table, num_partitions=2, collect_row_ids=collect_row_ids)
+ assert [ids.to_pylist() for ids in received] == ([selected_ids] if
selected_ids else [])
+ assert count == len(selected_ids)
+ assert actions == (selected_ids if collect_row_ids else [])
+ assert all(message.check_from_snapshot == snapshot_id for message in
messages)
+ table.new_batch_write_builder().new_commit().commit(messages)
+ read = table.new_read_builder()
+ rows =
read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
+ assert rows['id'].to_pylist() == ([2, 3] if selected_ids else [1, 2, 3, 4])
+
+
+def test_ray_delete_rejects_duplicate_ids_before_writing(tmp_path,
monkeypatch):
+ from pypaimon.ray.data_evolution_merge_join import distributed_delete_apply
+
+ table = _table(tmp_path)
+ _append(table, [1, 2])
+ scheduler = SimpleNamespace(
+ put=lambda value: pickle.loads(pickle.dumps(value)), get=lambda value:
value)
+ monkeypatch.setitem(sys.modules, 'ray', scheduler)
+ source = _dataset(pa.table({'_ROW_ID': pa.array([0, 0], type=pa.int64())}))
+ with patch.object(TableDeleteByRowId, 'delete',
side_effect=AssertionError('delete before validation')):
+ with pytest.raises(ValueError, match='multiple source rows'):
+ distributed_delete_apply(source, table, num_partitions=2)
diff --git a/paimon-python/pypaimon/tests/native_update_test.py
b/paimon-python/pypaimon/tests/native_update_test.py
index 894b54426a..d42409c090 100644
--- a/paimon-python/pypaimon/tests/native_update_test.py
+++ b/paimon-python/pypaimon/tests/native_update_test.py
@@ -30,6 +30,7 @@ from pypaimon.execution.native.write.native_update import
create_native_delete,
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.read.table_read import TableRead
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.backend_dispatch import native_row_id_update_selected
pytestmark = pytest.mark.native_plan
@@ -39,7 +40,6 @@ pytestmark = pytest.mark.native_plan
def
test_incremental_row_id_updater_uses_core_and_accumulates_columns(tmp_path,
stream):
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.row.generic_row import GenericRow
- from pypaimon.execution.native.write.native_update import
NativeTableUpdateByRowId
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
@@ -62,7 +62,8 @@ def
test_incremental_row_id_updater_uses_core_and_accumulates_columns(tmp_path,
with patch.object(PythonTableUpdateByRowId, '_load_existing_files_info',
side_effect=AssertionError('Python loaded the file
index')):
updater = update.new_update_by_row_id(commit_id) if stream else
update.new_update_by_row_id()
- assert isinstance(updater, NativeTableUpdateByRowId)
+ assert isinstance(updater, TableUpdateByRowId)
+ assert native_row_id_update_selected(updater)
assert updater.commit_user == builder.commit_user
assert updater.commit_identifier == commit_id
assert updater.commit_messages == []
@@ -91,7 +92,6 @@ def
test_incremental_row_id_updater_uses_core_and_accumulates_columns(tmp_path,
@pytest.mark.parametrize('stream', [False, True])
def test_partitioned_upsert_and_incremental_update_use_core(tmp_path, stream):
- from pypaimon.execution.native.write.native_update import
NativeTableUpdateByRowId
from pypaimon.table.row.generic_row import GenericRow
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
@@ -133,7 +133,8 @@ def
test_partitioned_upsert_and_incremental_update_use_core(tmp_path, stream):
current = read.new_read().to_arrow(read.new_scan().plan().splits())
selected = current.filter(pa.compute.equal(current['p'], 'a'))
updater = update.new_update_by_row_id(3) if stream else
update.new_update_by_row_id()
- assert isinstance(updater, NativeTableUpdateByRowId)
+ assert isinstance(updater, TableUpdateByRowId)
+ assert native_row_id_update_selected(updater)
messages = updater.update_columns(selected.select(['p', '_ROW_ID']), ['p'])
commit = builder.new_commit()
commit.commit(messages, 3) if stream else commit.commit(messages)
diff --git a/paimon-python/pypaimon/tests/public_execution_boundary_test.py
b/paimon-python/pypaimon/tests/public_execution_boundary_test.py
new file mode 100644
index 0000000000..b6f9730fdb
--- /dev/null
+++ b/paimon-python/pypaimon/tests/public_execution_boundary_test.py
@@ -0,0 +1,113 @@
+# 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.
+
+"""Public imports and actual Native temporal reads keep Python execution
unloaded."""
+
+import subprocess
+import sys
+
+import pytest
+
+from pypaimon.execution.native.runtime import native_runtime_available
+
+
+def test_public_imports_and_shared_validation_do_not_load_either_executor():
+ script = r'''
+import importlib
+import importlib.abc
+import sys
+
+class RejectExecutors(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.startswith(('pypaimon.execution.python.',
'pypaimon.execution.native.')):
+ raise AssertionError('Shared API loaded execution: ' + fullname)
+
+sys.meta_path.insert(0, RejectExecutors())
+for name in (
+ 'pypaimon.multimodal', 'pypaimon.api.rest_api',
'pypaimon.table.file_store_table',
+ 'pypaimon.catalog.table_query_auth', 'pypaimon.filesystem.local_file_io',
+ 'pypaimon.filesystem.pyarrow_file_io', 'pypaimon.ray.merge_condition',
+ 'pypaimon.ray.vector_index_build',
+):
+ importlib.import_module(name)
+import pypaimon.globalindex as indexes
+for name in indexes.__all__:
+ getattr(indexes, name)
+from pypaimon.schema.data_type_casts import supports_cast
+from pypaimon.schema.data_types import AtomicType
+assert supports_cast(AtomicType('INT'), AtomicType('BIGINT'))
+assert not any(name.startswith(('pypaimon.execution.python.',
'pypaimon.execution.native.'))
+ for name in sys.modules)
+'''
+ result = subprocess.run(
+ [sys.executable, '-B', '-c', script], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True, timeout=30)
+ assert result.returncode == 0, result.stderr
+
+
[email protected](not native_runtime_available(), reason='pypaimon-rust not
installed')
+def test_native_temporal_reads_never_import_python_execution(tmp_path):
+ script = r'''
+# NATIVE_TEMPORAL_BOUNDARY: the test requires the current SDK in this process.
+import importlib.abc
+import sys
+
+class RejectPythonExecution(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == 'pypaimon.execution.python' or
fullname.startswith('pypaimon.execution.python.'):
+ raise AssertionError('Native temporal loaded Python execution: ' +
fullname)
+
+sys.meta_path.insert(0, RejectPythonExecution())
+from pypaimon_rust.datafusion import SQLContext
+context = SQLContext()
+context.register_catalog('paimon', {'warehouse': sys.argv[1]})
+context.sql('CREATE SCHEMA paimon.temporal_boundary')
+for name, extra, values in (
+ ('anchors', '', '(1, 1), (1, 3)'),
+ ('samples', ', value INT', '(1, 0, 0), (1, 4, 40)'),
+):
+ definition = "CREATE TABLE paimon.temporal_boundary." + name + " (episode
INT, event_time BIGINT" + extra
+ context.sql(definition + """) WITH (
+ 'row-tracking.enabled' = 'true', 'data-evolution.enabled' = 'true')""")
+ columns = '(episode, event_time)' if name == 'anchors' else '(episode,
event_time, value)'
+ context.sql('INSERT INTO paimon.temporal_boundary.' + name + ' ' + columns
+ ' VALUES ' + values)
+
+from pypaimon import CatalogFactory
+from pypaimon.multimodal import join_asof, interpolate, join_window
+from pypaimon.multimodal.query import ScanQuery
+catalog = CatalogFactory.create({'warehouse': sys.argv[1]})
+options = {'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true'}
+anchors =
ScanQuery(catalog.get_table('temporal_boundary.anchors').copy(options))
+samples =
ScanQuery(catalog.get_table('temporal_boundary.samples').copy(options)).select('value')
+asof = join_asof(anchors, samples, on='event_time', by='episode')
+assert asof.to_arrow()['value'].to_pylist() == [0, 0]
+linear = interpolate(anchors, samples, on='event_time', by='episode')
+assert linear.to_arrow()['value'].to_pylist() == [10., 30.]
+window = join_window(anchors, samples, on='event_time', by='episode',
+ preceding=1, following=1, aggregations={'mean_value':
('value', 'mean')})
+assert window.to_arrow()['mean_value'].to_pylist() == [0., 40.]
+reader = asof.to_arrow_batch_reader(batch_size=1)
+try:
+ assert reader.read_next_batch().num_rows == 1
+finally:
+ reader.close()
+assert not any(name.startswith('pypaimon.execution.python') for name in
sys.modules)
+'''
+ result = subprocess.run(
+ [sys.executable, '-B', '-c', script, str(tmp_path)],
stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True, timeout=90)
+ assert result.returncode == 0, result.stderr
diff --git
a/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
b/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
new file mode 100644
index 0000000000..7869fedb20
--- /dev/null
+++ b/paimon-python/pypaimon/tests/public_execution_factory_boundary_test.py
@@ -0,0 +1,209 @@
+# 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.
+
+"""Public factories retain captured inputs and enter their selected dispatch
boundary."""
+
+import subprocess
+import sys
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.source.batch_vector_search_builder import
BatchVectorSearchBuilderImpl
+from pypaimon.table.source.full_text_search_builder import
FullTextSearchBuilderImpl
+from pypaimon.table.source.vector_search_builder import VectorSearchBuilderImpl
+
+
+def _search_table(path, native):
+ catalog = CatalogFactory.create({'warehouse': str(path)})
+ catalog.create_database('default', True)
+ catalog.create_table('default.search', Schema.from_pyarrow_schema(
+ pa.schema([('embedding', pa.list_(pa.float32())), ('body',
pa.string())]),
+ options={'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true',
+ 'read.native.enabled': str(native).lower()}), False)
+ return catalog.get_table('default.search')
+
+
[email protected]('kind', ['vector', 'batch-vector', 'full-text'])
[email protected]('phase', ['scan', 'read'])
[email protected]('native', [False, True])
+def test_search_factories_keep_captured_configuration(tmp_path, kind, phase,
native):
+ from pypaimon.table.source.search_dispatch import python_search_reader
+
+ table = _search_table(tmp_path, native)
+ query = [1.0, 2.0]
+ if kind == 'vector':
+ builder =
(VectorSearchBuilderImpl(table).with_vector_column('embedding')
+
.with_query_vector(query).with_limit(2).with_option('ivf.nprobe', '1'))
+ elif kind == 'batch-vector':
+ builder =
(BatchVectorSearchBuilderImpl(table).with_vector_column('embedding')
+
.with_query_vectors([query]).with_limit(2).with_option('ivf.nprobe', '1'))
+ else:
+ builder = FullTextSearchBuilderImpl(table).with_query('body',
'term').with_limit(2)
+ family = 'full_text' if kind == 'full-text' else 'vector'
+ python_name = ('new_batch_vector_read' if kind == 'batch-vector' and phase
== 'read'
+ else 'new_' + family + '_' + phase)
+ native_name = 'native_' + family + '_' + phase
+ python_execution, native_execution = Mock(), Mock()
+ with patch('pypaimon.execution.python.read.search.factories.' +
python_name,
+ return_value=python_execution) as python_factory, \
+ patch('pypaimon.execution.native.read.native_search_plan.' +
native_name,
+ return_value=native_execution) as native_factory:
+ if phase == 'scan':
+ selected = (builder.new_full_text_scan() if kind == 'full-text'
+ else builder.new_vector_search_scan())
+ else:
+ selected = (builder.new_full_text_read() if kind == 'full-text'
+ else builder.new_batch_vector_search_read() if kind ==
'batch-vector'
+ else builder.new_vector_search_read())
+ if native:
+ python_factory.assert_not_called()
+ native_factory.assert_called_once()
+ else:
+ native_factory.assert_not_called()
+ builder.with_limit(9)
+ if kind == 'full-text':
+ builder.with_query('body', 'later')
+ else:
+ query[0] = 99.0
+ builder.with_option('ivf.nprobe', '9')
+ if phase == 'scan':
+ native_execution.available.return_value = False
+ assert selected.scan() is python_execution.scan.return_value
+ else:
+ assert python_search_reader(selected) is python_execution
+ python_factory.assert_called_once()
+ captured = python_factory.call_args[0][0]
+ assert captured is not builder and captured._limit == 2
+ if kind == 'full-text':
+ assert captured._query == 'term'
+ else:
+ assert captured._options == {'ivf.nprobe': '1'}
+ if phase == 'read':
+ assert (captured._query_vector if kind == 'vector' else
captured._query_vectors[0]) == [1.0, 2.0]
+
+
+def test_native_search_factories_do_not_import_python_execution(tmp_path):
+ source = r"""
+import importlib.abc
+import sys
+from unittest.mock import Mock, patch
+import pyarrow as pa
+
+class RejectPythonExecution(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname.startswith('pypaimon.execution.python'):
+ raise AssertionError('Native factory imported Python execution: '
+ fullname)
+
+sys.meta_path.insert(0, RejectPythonExecution())
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.source.vector_search_builder import VectorSearchBuilderImpl
+from pypaimon.table.source.batch_vector_search_builder import
BatchVectorSearchBuilderImpl
+from pypaimon.table.source.full_text_search_builder import
FullTextSearchBuilderImpl
+from pypaimon.execution.native.read import native_search_plan
+
+catalog = CatalogFactory.create({'warehouse': sys.argv[1]})
+catalog.create_database('default', True)
+catalog.create_table('default.search', Schema.from_pyarrow_schema(
+ pa.schema([('embedding', pa.list_(pa.float32())), ('body', pa.string())]),
+ options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'read.native.enabled': 'true'}), False)
+table = catalog.get_table('default.search')
+vector = (VectorSearchBuilderImpl(table).with_vector_column('embedding')
+ .with_query_vector([1., 2.]).with_limit(2))
+batch = (BatchVectorSearchBuilderImpl(table).with_vector_column('embedding')
+ .with_query_vectors([[1., 2.]]).with_limit(2))
+text = FullTextSearchBuilderImpl(table).with_query('body',
'term').with_limit(2)
+native = Mock()
+native.available.return_value = True
+expected = object()
+for name in ['scan', 'read_plan', 'read_batch_plan']:
+ getattr(native, name).return_value = expected
+constructors = {name: Mock(return_value=native) for name in [
+ 'native_vector_scan', 'native_vector_read', 'native_full_text_scan',
'native_full_text_read']}
+with patch.multiple(native_search_plan, **constructors):
+ operations = [
+ lambda: vector.new_vector_search_scan().scan(),
+ lambda: vector.new_vector_search_read().read_plan(object()),
+ lambda: batch.new_vector_search_scan().scan(),
+ lambda: batch.new_batch_vector_search_read().read_batch_plan(object()),
+ lambda: text.new_full_text_scan().scan(),
+ lambda: text.new_full_text_read().read_plan(object()),
+ ]
+ for operation in operations:
+ assert operation() is expected
+ failure = OSError('selected native executor failed')
+ for name in ['scan', 'read_plan', 'read_batch_plan']:
+ getattr(native, name).side_effect = failure
+ for operation in operations:
+ try:
+ operation()
+ except OSError as error:
+ assert error is failure
+ else:
+ raise AssertionError('Native failure was hidden')
+assert not any(name.startswith('pypaimon.execution.python') for name in
sys.modules)
+"""
+ result = subprocess.run(
+ [sys.executable, '-c', source, str(tmp_path)], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True, timeout=45)
+ assert result.returncode == 0, result.stdout + result.stderr
+
+
[email protected]('bucket,primary_keys,expected', [
+ ('2', ['id', 'p'], 'FixedBucketRowKeyExtractor'),
+ ('-1', [], 'UnawareBucketRowKeyExtractor'),
+ ('-2', ['id', 'p'], 'PostponeBucketRowKeyExtractor'),
+ ('-1', ['id', 'p'], 'DynamicBucketRowKeyExtractor'),
+])
+def test_public_row_key_factory_preserves_per_row_contract(tmp_path, bucket,
primary_keys, expected):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ arrow_schema = pa.schema([('id', pa.int32()), ('p', pa.string())])
+ catalog.create_table('default.keys', Schema.from_pyarrow_schema(
+ arrow_schema, primary_keys=primary_keys, partition_keys=['p'],
options={
+ 'bucket': bucket, 'write.native.enabled': 'true',
'read.native.enabled': 'false',
+ 'scan.native-plan.enabled': 'false', 'commit.native.enabled':
'false'}), False)
+ table = catalog.get_table('default.keys')
+ rows = pa.record_batch([pa.array([1, 2], pa.int32()), pa.array(['a',
'a'])], schema=arrow_schema)
+ with patch('pypaimon.execution.native.runtime.native_runtime_available',
+ side_effect=AssertionError('Per-row helper rediscovered the
Native writer')):
+ extractor = table.create_row_key_extractor(ignore_existing=True)
+ partitions, buckets = extractor.extract_partition_bucket_batch(rows)
+ assert type(extractor).__name__ == expected
+ assert partitions == [('a',), ('a',)]
+ if bucket == '2':
+ assert all(0 <= value < 2 for value in buckets)
+ else:
+ assert buckets == [-2 if bucket == '-2' else 0] * 2
+
+
+def test_ray_condition_compatibility_helpers_keep_python_execution():
+ if sys.version_info < (3, 10):
+ pytest.skip('Python MERGE conditions require Python 3.10')
+ pytest.importorskip('datafusion')
+ from pypaimon.ray.merge_condition import apply_condition, filter_batch,
rewrite_condition
+
+ rows = pa.table({'s.id': pa.chunked_array([[1, 2], [3]]), 't.id': [2, 1,
2]})
+ with patch('pypaimon.execution.native.runtime.native_runtime_available',
+ side_effect=AssertionError('Python condition helper discovered
Native execution')):
+ assert filter_batch(rows, 's.id > t.id').column('s.id').to_pylist() ==
[2, 3]
+ schema = pa.schema([('s.id', pa.int64())])
+ empty = apply_condition(rows, rewrite_condition('s.id < 0'), schema)
+ assert empty.num_rows == 0 and empty.schema == schema
diff --git a/paimon-python/pypaimon/tests/schema_cast_support_test.py
b/paimon-python/pypaimon/tests/schema_cast_support_test.py
new file mode 100644
index 0000000000..c5fdfaca2b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/schema_cast_support_test.py
@@ -0,0 +1,115 @@
+# 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.
+
+"""Schema cast validation describes capabilities without loading execution."""
+
+from pathlib import Path
+import subprocess
+import sys
+import textwrap
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.schema.data_type_casts import can_execute_cast
+from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+
+
+_LEAF_TYPES = [
+ 'CHAR(3)', 'VARCHAR(5)', 'BOOLEAN', 'BINARY(3)', 'VARBINARY(5)',
+ 'DECIMAL(4,2)', 'DECIMAL(38,18)', 'TINYINT', 'SMALLINT', 'INT', 'BIGINT',
+ 'FLOAT', 'DOUBLE', 'DATE', 'TIME', 'TIME(3)',
+ 'TIMESTAMP(0)', 'TIMESTAMP(3)', 'TIMESTAMP(6)', 'TIMESTAMP(9)',
+ 'TIMESTAMP_LTZ(0)', 'TIMESTAMP_LTZ(3)', 'TIMESTAMP_LTZ(6)',
'TIMESTAMP_LTZ(9)',
+ 'TIMESTAMP(6) WITH LOCAL TIME ZONE', 'BLOB', 'VARIANT',
+]
+
+
[email protected]('source', _LEAF_TYPES)
+def
test_declared_cast_capabilities_match_existing_read_materialization(source):
+ from pypaimon.execution.python.casting.schema_evolution import
cast_array_for_schema_evolution
+
+ source_type = AtomicType(source)
+ values = pa.nulls(1, type=PyarrowFieldParser.from_paimon_type(source_type))
+ for target in _LEAF_TYPES:
+ target_type = AtomicType(target)
+ try:
+ cast_array_for_schema_evolution(
+ values, PyarrowFieldParser.from_paimon_type(target_type),
source_type, target_type)
+ executable = True
+ except (pa.ArrowNotImplementedError, pa.ArrowInvalid,
pa.ArrowTypeError,
+ ValueError, NotImplementedError):
+ executable = False
+ # TIME precision is not represented by its Arrow storage unit, so
+ # changing it is rejected independently of Arrow's identity kernel.
+ if (source, target) in (('TIME', 'TIME(3)'), ('TIME(3)', 'TIME')):
+ executable = False
+ assert can_execute_cast(source_type, target_type) == executable, '{}
-> {}'.format(source, target)
+
+
[email protected](sys.version_info < (3, 7), reason='Python 3.6 retains
eager package exports')
+def test_schema_type_changes_do_not_import_or_execute_either_backend():
+ script = r"""
+import importlib.abc
+import sys
+from unittest.mock import patch
+
+class RejectExecution(importlib.abc.MetaPathFinder):
+ def find_spec(self, fullname, path=None, target=None):
+ if fullname == 'pypaimon.execution' or
fullname.startswith('pypaimon.execution.'):
+ raise AssertionError('Schema validation imported execution: ' +
fullname)
+
+sys.meta_path.insert(0, RejectExecution())
+from pypaimon.schema.data_type_casts import can_execute_cast
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.schema.schema_manager import SchemaManager
+from pypaimon.schema.table_schema import TableSchema
+
+manager = SchemaManager(None, '/unused-schema-capabilities')
+with patch('pyarrow.nulls', side_effect=AssertionError('Schema validation
executed a cast probe')):
+ for source, target, supported in [
+ ('INT', 'BIGINT', True), ('INT', 'TIMESTAMP(3)', True),
+ ('BIGINT', 'TIMESTAMP_LTZ(6)', True), ('INT', 'DECIMAL(4,2)', True),
+ ('TIMESTAMP_LTZ(9)', 'TIMESTAMP(3)', True),
+ ('TIMESTAMP(3)', 'DECIMAL(10,2)', False), ('BOOLEAN', 'DECIMAL(4,2)',
False),
+ ('TIME', 'TIMESTAMP(6)', False), ('TIME(3)', 'TIME(1)', False),
+ ]:
+ source_type, target_type = AtomicType(source), AtomicType(target)
+ assert can_execute_cast(source_type, target_type) == supported,
(source, target)
+ schema = TableSchema(fields=[DataField(0, 'v', source_type)])
+ change = SchemaChange.update_column_type('v', target_type)
+ if supported:
+ changed = manager._generate_table_schema(schema, [change])
+ assert changed.fields[0].id == 0 and changed.fields[0].type ==
target_type
+ assert schema.fields[0].type == source_type
+ else:
+ try:
+ manager._generate_table_schema(schema, [change])
+ except ValueError as error:
+ assert 'no executable cast' in str(error)
+ else:
+ raise AssertionError('Unsupported schema cast was accepted: '
+ str((source, target)))
+assert not any(name == 'pypaimon.execution' or
name.startswith('pypaimon.execution.')
+ for name in sys.modules)
+"""
+ result = subprocess.run(
+ [sys.executable, '-c', textwrap.dedent(script)],
+ cwd=str(Path(__file__).resolve().parents[2]),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True,
+ )
+ assert result.returncode == 0, result.stdout + result.stderr
diff --git a/paimon-python/pypaimon/tests/write/row_id_delete_input_test.py
b/paimon-python/pypaimon/tests/write/row_id_delete_input_test.py
new file mode 100644
index 0000000000..b07aa383b2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/row_id_delete_input_test.py
@@ -0,0 +1,175 @@
+# 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.
+
+"""Arrow row-ID deletes preserve the same snapshot and bitmap semantics."""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.execution.native.runtime import native_runtime_available
+from pypaimon.execution.native.write.native_update import
NativeBatchTableUpdate
+from pypaimon.write.row_id_plan import capture_row_id_plan
+from pypaimon.write.row_input import normalize_row_ids
+from pypaimon.write.table_delete import TableDeleteByRowId
+
+pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
+ pytest.mark.python_write, pytest.mark.python_commit]
+_SCHEMA = pa.schema([('id', pa.int32()), ('v', pa.int32())])
+_ENGINES = [False, pytest.param(True, marks=pytest.mark.skipif(
+ not native_runtime_available(), reason='pypaimon-rust not installed'))]
+
+
[email protected]
+def table(tmp_path):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(_SCHEMA,
options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'deletion-vectors.enabled': 'true', 'write.native.enabled': 'false',
+ 'commit.native.enabled': 'false', 'read.native.enabled': 'false',
+ 'scan.native-plan.enabled': 'false'}), False)
+ result = catalog.get_table('default.t')
+ _append(result, [0, 1, 2, 3])
+ return result
+
+
+def _append(table, ids):
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'id': ids, 'v': ids}, schema=_SCHEMA))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _commit(table, messages):
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+
+
+def _ids(table):
+ read = table.new_read_builder().with_projection(['id'])
+ return
sorted(read.new_read().to_arrow(read.new_scan().plan().splits())['id'].to_pylist())
+
+
[email protected]('native', _ENGINES)
[email protected]('entry', ['direct', 'batch', 'stream'])
[email protected]('layout', ['sequence', 'slice', 'chunked'])
+def test_row_id_delete_inputs_are_idempotent(table, native, entry, layout):
+ worker_table = table.copy({'write.native.enabled': str(native).lower()})
+ row_ids = {
+ 'sequence': [1, 3, 1],
+ 'slice': pa.array([99, 1, 3, 1, 99], type=pa.uint32()).slice(1, 3),
+ 'chunked': pa.chunked_array([[], [1, 3], [], [1]], type=pa.int16()),
+ }[layout]
+ if entry == 'direct':
+ messages = TableDeleteByRowId(worker_table).delete(row_ids)
+ else:
+ builder = (worker_table.new_stream_write_builder() if entry == 'stream'
+ else worker_table.new_batch_write_builder())
+ update = builder.new_update()
+ messages = update.delete_by_row_id(row_ids, 7) if entry == 'stream'
else update.delete_by_row_id(row_ids)
+ assert messages and all(message.check_from_snapshot == 1 for message in
messages)
+ _commit(table, messages)
+ assert _ids(table) == [0, 2]
+ assert TableDeleteByRowId(worker_table).delete(row_ids) == []
+
+
[email protected]('native', _ENGINES)
[email protected]('row_ids,error', [
+ (pa.array([0.9]), TypeError),
+ (pa.array([True]), TypeError),
+ (pa.array([], type=pa.float64()), TypeError),
+ (pa.array([None]), TypeError),
+ (pa.array([0, None], type=pa.int64()), ValueError),
+ (pa.chunked_array([[0], [None]], type=pa.int16()), ValueError),
+ (pa.array([0, 1 << 63], type=pa.uint64()), pa.ArrowInvalid),
+ (pa.chunked_array([[0], [1 << 63]], type=pa.uint64()), pa.ArrowInvalid),
+])
+def test_invalid_arrow_ids_cannot_stage_a_partial_delete(table, native,
row_ids, error):
+ table = table.copy({'write.native.enabled': str(native).lower()})
+ before = set(Path(table.table_path).rglob('*'))
+ update = table.new_batch_write_builder().new_update()
+ with pytest.raises(error):
+ update.delete_by_row_id(row_ids)
+ assert set(Path(table.table_path).rglob('*')) == before
+ assert _ids(table) == [0, 1, 2, 3]
+
+
[email protected]('native', _ENGINES)
+def test_captured_arrow_delete_preserves_other_anchors_and_new_appends(table,
native):
+ _append(table, [4, 5])
+ _commit(table, TableDeleteByRowId(table).delete([0]))
+ worker_table = table.copy({'write.native.enabled': str(native).lower()})
+ captured = capture_row_id_plan(worker_table)
+ captured = captured.for_split(next(split for start, _, split in
captured.groups if start == 4))
+ _append(table, [6])
+ with patch('pypaimon.read.table_scan.TableScan.plan_all',
side_effect=AssertionError('worker replanned')):
+ messages = TableDeleteByRowId(worker_table, captured).delete(
+ pa.chunked_array([[4], [], [4]], type=pa.int32()))
+ assert all(message.check_from_snapshot == 3 for message in messages)
+ assert sum(len(message.index_deletes) for message in messages) == 1
+ _commit(table, messages)
+ assert _ids(table) == [1, 2, 3, 5, 6]
+
+
[email protected]('row_ids', [pa.array([], type=pa.int32()),
pa.chunked_array([], type=pa.int64()), []])
+def test_empty_delete_keeps_native_execution_selected(table, row_ids):
+ from pypaimon.write import update_dispatch
+
+ class EmptyNativeDelete:
+ def delete_by_row_id(self, ids):
+ assert len(ids) == 0
+ return []
+
+ with patch.object(update_dispatch, 'prepare_native_write_operation',
return_value=EmptyNativeDelete()), \
+ patch.object(update_dispatch, 'create_python_update_operation',
+ side_effect=AssertionError('Python loaded')):
+ assert
table.new_batch_write_builder().new_update().delete_by_row_id(row_ids) == []
+
+
+def test_native_adapter_preserves_arrow_chunks_and_buffer_ownership(table):
+ original = pa.chunked_array([pa.array([99, 1, 3],
type=pa.int64()).slice(1),
+ pa.array([0], type=pa.int64())])
+ visited = []
+
+ class ArrowOnlyDelete:
+ def delete_by_row_id(self, ids):
+ assert isinstance(ids, pa.ChunkedArray)
+ assert ids.num_chunks == 2 and ids.chunk(0).offset == 1
+ assert [chunk.buffers()[1].address for chunk in ids.chunks] == [
+ chunk.buffers()[1].address for chunk in original.chunks]
+ visited.append(ids)
+ return []
+
+ assert NativeBatchTableUpdate(table,
ArrowOnlyDelete()).delete_by_row_id(original) == []
+ assert len(visited) == 1
+
+
+def test_sequence_callers_keep_integer_coercion():
+ assert normalize_row_ids([1, '2', 3.9, True]) == [1, 2, 3, 1]
+ with pytest.raises(ValueError, match='must not be null'):
+ normalize_row_ids([0, None])
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index e105329da0..7ba5342b7c 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -246,10 +246,10 @@ class FileStorePathFactory:
if not self.external_paths:
return None
- from pypaimon.execution.python.write.external_path_provider import
ExternalPathProvider
+ from pypaimon.write.backend_dispatch import
create_external_path_provider
relative_bucket_path = self.relative_bucket_path(partition, bucket,
canonical_partition)
- return ExternalPathProvider.create(
+ return create_external_path_provider(
self.external_path_strategy,
self.external_paths,
relative_bucket_path,
diff --git a/paimon-python/pypaimon/write/backend_dispatch.py
b/paimon-python/pypaimon/write/backend_dispatch.py
index 0181174f5a..ab773ac5af 100644
--- a/paimon-python/pypaimon/write/backend_dispatch.py
+++ b/paimon-python/pypaimon/write/backend_dispatch.py
@@ -459,6 +459,15 @@ def native_row_id_update_selected(worker):
return isinstance(execution, NativeTableUpdateByRowId)
+def validate_row_id_update_rows(worker, captured_plan, first_row_id, row_ids):
+ """Validate liveness in the selected executor before positional
rewriting."""
+ if native_row_id_update_selected(worker):
+ # Core validates the captured anchor's DV before staging any output.
+ return
+ from pypaimon.execution.python.write.row_id_plan import
validate_live_row_ids
+ validate_live_row_ids(worker.table, captured_plan, first_row_id, row_ids)
+
+
def python_commit(owner):
"""Create Python commit resources only after that backend is selected."""
if owner._python_commit is None:
@@ -554,3 +563,70 @@ def close_table_commit(owner):
callback.close()
except Exception:
pass
+
+
+def create_row_key_extractor(table, ignore_existing=False):
+ """Construct the public per-row extractor through the Python write
boundary."""
+ from pypaimon.table.bucket_mode import BucketMode
+ from pypaimon.execution.python.write.row_key_extractor import (
+ DynamicBucketRowKeyExtractor, FixedBucketRowKeyExtractor,
+ PostponeBucketRowKeyExtractor, UnawareBucketRowKeyExtractor,
+ )
+
+ bucket_mode = table.bucket_mode()
+ if bucket_mode == BucketMode.HASH_FIXED:
+ return FixedBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.BUCKET_UNAWARE:
+ return UnawareBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.POSTPONE_MODE:
+ return PostponeBucketRowKeyExtractor(table.table_schema)
+ elif bucket_mode == BucketMode.HASH_DYNAMIC:
+ return DynamicBucketRowKeyExtractor(
+ table.table_schema, table=table, ignore_existing=ignore_existing)
+ elif bucket_mode == BucketMode.CROSS_PARTITION:
+ raise ValueError(
+ "CROSS_PARTITION primary-key writes require a persistent "
+ "global primary-key index, which PyPaimon does not yet support"
+ )
+ raise ValueError(f"Unsupported bucket mode: {bucket_mode}")
+
+
+def filter_merge_condition_batch(batch, condition, pre_rewritten=False):
+ """Execute the public Python condition helper without selecting a table
writer."""
+ from pypaimon.execution.python.write.merge_condition import filter_batch
+ return filter_batch(batch, condition, pre_rewritten)
+
+
+def apply_merge_condition(batch, rewritten, empty_schema):
+ from pypaimon.execution.python.write.merge_condition import apply_condition
+ return apply_condition(batch, rewritten, empty_schema)
+
+
+def create_external_path_provider(strategy, paths, relative_bucket_path,
weights):
+ from pypaimon.execution.python.write.external_path_provider import
ExternalPathProvider
+ return ExternalPathProvider.create(strategy, paths, relative_bucket_path,
weights)
+
+
+def execute_row_id_conflict_check(table, messages, latest_snapshot_id,
check_from_snapshot):
+ if table.options.native_commit_enabled():
+ from pypaimon.execution.native.write.native_commit import
prepare_native_row_id_conflict_check
+ # Unsupported configurations return None explicitly. Permission or
+ # transport failures must never restart the check in another backend.
+ native = prepare_native_row_id_conflict_check(table, messages)
+ if native is not None:
+ commit, native_messages = native
+ failure = None
+ try:
+ commit.check_row_id_conflicts(native_messages,
latest_snapshot_id, check_from_snapshot)
+ except BaseException as error:
+ failure = error
+ raise
+ finally:
+ try:
+ commit.close()
+ except BaseException:
+ if failure is None:
+ raise
+ return
+ from pypaimon.execution.python.write.commit.row_id_conflict import
check_row_id_conflicts as check
+ check(table, messages, latest_snapshot_id, check_from_snapshot)
diff --git a/paimon-python/pypaimon/write/row_id_conflict.py
b/paimon-python/pypaimon/write/row_id_conflict.py
index 61cda36f30..db4d8a264b 100644
--- a/paimon-python/pypaimon/write/row_id_conflict.py
+++ b/paimon-python/pypaimon/write/row_id_conflict.py
@@ -108,25 +108,5 @@ def check_row_id_conflicts(table, messages,
latest_snapshot_id, check_from_snaps
if (not baselines or invalid_baseline or min(baselines) !=
check_from_snapshot or missing_baseline):
raise ValueError('Row-ID conflict check baseline must match the commit
messages')
_authorize_update(table)
- if table.options.native_commit_enabled():
- from pypaimon.execution.native.write.native_commit import
prepare_native_row_id_conflict_check
- # Unsupported configurations return None explicitly. Permission or
- # transport failures must never restart the check in another backend.
- native = prepare_native_row_id_conflict_check(table, messages)
- if native is not None:
- commit, native_messages = native
- failure = None
- try:
- commit.check_row_id_conflicts(native_messages,
latest_snapshot_id, check_from_snapshot)
- except BaseException as error:
- failure = error
- raise
- finally:
- try:
- commit.close()
- except BaseException:
- if failure is None:
- raise
- return
- from pypaimon.execution.python.write.commit.row_id_conflict import
check_row_id_conflicts as check
- check(table, messages, latest_snapshot_id, check_from_snapshot)
+ from pypaimon.write.backend_dispatch import execute_row_id_conflict_check
+ execute_row_id_conflict_check(table, messages, latest_snapshot_id,
check_from_snapshot)
diff --git a/paimon-python/pypaimon/write/row_id_plan.py
b/paimon-python/pypaimon/write/row_id_plan.py
index 63de3f02d8..74fb5d08ad 100644
--- a/paimon-python/pypaimon/write/row_id_plan.py
+++ b/paimon-python/pypaimon/write/row_id_plan.py
@@ -103,9 +103,8 @@ def _row_id_array(row_ids):
"""Accept integer Arrow inputs without silently truncating floating IDs."""
if not isinstance(row_ids, (pa.Array, pa.ChunkedArray)) or not
pa.types.is_integer(row_ids.type):
raise TypeError('_ROW_ID input must be an integer Arrow array')
- if row_ids.null_count:
- raise ValueError('_ROW_ID is null; planner snapshot is stale or row
IDs come from a different table.')
- row_ids = row_ids.cast(pa.int64(), safe=True)
+ from pypaimon.write.row_input import normalize_row_ids
+ row_ids = normalize_row_ids(row_ids)
return row_ids.combine_chunks() if isinstance(row_ids, pa.ChunkedArray)
else row_ids
diff --git a/paimon-python/pypaimon/write/row_input.py
b/paimon-python/pypaimon/write/row_input.py
index 120abb3752..e0c4a702e8 100644
--- a/paimon-python/pypaimon/write/row_input.py
+++ b/paimon-python/pypaimon/write/row_input.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Sequence, Union
import pyarrow as pa
@@ -25,6 +25,30 @@ from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_utils import (inline_blob_value, require_columns,
row_to_named_values, value_for_arrow)
+RowIdInput = Union[Sequence[int], pa.Array, pa.ChunkedArray]
+
+
+def normalize_row_ids(row_ids: RowIdInput):
+ """Validate IDs without turning Arrow buffers into Python objects.
+
+ Keep chunk boundaries for Native transport. Only integer Arrow layouts
+ have a row-ID identity; floating, Boolean and untyped NULL arrays must not
+ be silently converted. Existing sequence callers retain integer coercion.
+ """
+ if isinstance(row_ids, (pa.Array, pa.ChunkedArray)):
+ if not pa.types.is_integer(row_ids.type):
+ raise TypeError('_ROW_ID input must be an integer Arrow array')
+ if row_ids.null_count:
+ raise ValueError('_ROW_ID value must not be null.')
+ return row_ids.cast(pa.int64(), safe=True)
+ values = []
+ for row_id in row_ids:
+ if row_id is None:
+ raise ValueError('_ROW_ID value must not be null.')
+ values.append(int(row_id))
+ return values
+
+
class RowIdUpdateInputMixin:
"""Normalize Python row inputs for either update execution backend."""
diff --git a/paimon-python/pypaimon/write/table_delete.py
b/paimon-python/pypaimon/write/table_delete.py
index c00bafaa81..544dbe0c52 100644
--- a/paimon-python/pypaimon/write/table_delete.py
+++ b/paimon-python/pypaimon/write/table_delete.py
@@ -15,9 +15,10 @@
# specific language governing permissions and limitations
# under the License.
-from typing import TYPE_CHECKING, List, Optional, Sequence
+from typing import TYPE_CHECKING, List, Optional
from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.row_input import RowIdInput, normalize_row_ids
if TYPE_CHECKING:
from pypaimon.execution.python.write.table_delete import _AnchorRangesInfo
@@ -40,8 +41,9 @@ class TableDeleteByRowId:
owner._execution = create_python_row_id_delete(table,
_precomputed_anchor_ranges)
return owner
- def delete(self, row_ids: Sequence[int]) -> List[CommitMessage]:
- return self._execution.delete(row_ids)
+ def delete(self, row_ids: RowIdInput) -> List[CommitMessage]:
+ """Delete sequence or integer Arrow IDs; duplicate IDs are
idempotent."""
+ return self._execution.delete(normalize_row_ids(row_ids))
def _snapshot_anchor_ranges(self):
# Ray's metadata coordinator selects _from_python explicitly. Native
diff --git a/paimon-python/pypaimon/write/table_update.py
b/paimon-python/pypaimon/write/table_update.py
index a97863e989..aca41ec9e9 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.commit_message import CommitMessage
+from pypaimon.write.row_input import RowIdInput
from pypaimon.write.commit_identifier import validate_commit_identifier
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
@@ -124,7 +125,7 @@ class TableUpdate:
from pypaimon.write.update_dispatch import TableUpdateOperations
return TableUpdateOperations(self)._delete_by_predicate(predicate,
commit_identifier)
- def _delete_by_row_id(self, row_ids: Sequence[int], commit_identifier:
int) -> List[CommitMessage]:
+ def _delete_by_row_id(self, row_ids: RowIdInput, commit_identifier: int)
-> List[CommitMessage]:
from pypaimon.write.update_dispatch import TableUpdateOperations
return TableUpdateOperations(self)._delete_by_row_id(row_ids,
commit_identifier)
@@ -194,12 +195,12 @@ class BatchTableUpdate(TableUpdate):
"""Delete rows matching ``predicate`` using deletion vectors."""
return self._delete_by_predicate(predicate, BATCH_COMMIT_IDENTIFIER)
- def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]:
- """Delete rows by ``_ROW_ID`` using deletion vectors."""
+ def delete_by_row_id(self, row_ids: RowIdInput) -> List[CommitMessage]:
+ """Delete sequence or integer Arrow ``_ROW_ID`` values using deletion
vectors."""
return self._delete_by_row_id(row_ids, BATCH_COMMIT_IDENTIFIER)
def _delete_by_row_id(
- self, row_ids: Sequence[int], commit_identifier: int
+ self, row_ids: RowIdInput, commit_identifier: int
) -> List[CommitMessage]:
return super()._delete_by_row_id(row_ids, commit_identifier)
@@ -293,7 +294,7 @@ class StreamTableUpdate(TableUpdate):
def delete_by_row_id(
self,
- row_ids: Sequence[int],
+ row_ids: RowIdInput,
commit_identifier: int,
) -> List[CommitMessage]:
"""Delete rows by ``_ROW_ID`` using deletion vectors,
diff --git a/paimon-python/pypaimon/write/update_dispatch.py
b/paimon-python/pypaimon/write/update_dispatch.py
index 383192225c..40aa0a63d4 100644
--- a/paimon-python/pypaimon/write/update_dispatch.py
+++ b/paimon-python/pypaimon/write/update_dispatch.py
@@ -30,6 +30,7 @@ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_utils import row_to_named_values
+from pypaimon.write.row_input import RowIdInput, normalize_row_ids
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
from pypaimon.write.update_validation import _authorize_update
@@ -155,11 +156,10 @@ class TableUpdateOperations:
operation = create_python_update_operation(context,
commit_identifier, predicate)
return operation.delete()
- def _delete_by_row_id(self, row_ids: Sequence[int], commit_identifier:
int) -> List[CommitMessage]:
+ def _delete_by_row_id(self, row_ids: RowIdInput, commit_identifier: int)
-> List[CommitMessage]:
context = self._context
- operation = None
- if len(row_ids):
- operation = prepare_native_write_operation('delete',
context.table, context.commit_user)
+ row_ids = normalize_row_ids(row_ids)
+ operation = prepare_native_write_operation('delete', context.table,
context.commit_user)
if operation is None:
operation = create_python_update_operation(context,
commit_identifier)
return operation.delete_by_row_id(row_ids)