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 ba6a122499 [python] Support callable assignments in Ray self-merge
(#9327)
ba6a122499 is described below
commit ba6a122499e21efbe964c62a2923ac4c9146121b
Author: XiaoHongbo <[email protected]>
AuthorDate: Fri Aug 21 13:10:13 2026 +0800
[python] Support callable assignments in Ray self-merge (#9327)
---
docs/docs/pypaimon/ray-data.md | 26 +++
.../pypaimon/ray/data_evolution_merge_into.py | 43 +++-
.../pypaimon/ray/data_evolution_merge_join.py | 17 ++
.../pypaimon/ray/data_evolution_merge_transform.py | 30 ++-
.../tests/ray_data_evolution_merge_into_test.py | 248 +++++++++++++++++++++
5 files changed, 359 insertions(+), 5 deletions(-)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 3f90068336..1f9194c92c 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -513,11 +513,37 @@ extra. Install the extra before using conditions: `pip
install pypaimon[sql]`.
]
```
+For self-merge (`source == target` and `on=["_ROW_ID"]`), update values may
+also be callables. A callable receives the matched `read_columns` plus
+`_ROW_ID` as a `pyarrow.Table` and must return one `pyarrow.Array` or
+`pyarrow.ChunkedArray` value per input row:
+
+```python
+import pyarrow.compute as pc
+
+merge_into(
+ target="db.table",
+ source="db.table",
+ catalog_options=catalog_options,
+ on=["_ROW_ID"],
+ read_columns=["age"],
+ when_matched=[WhenMatched.update({
+ "age": lambda rows: pc.add(rows["age"], 1),
+ }, condition="t.id IN (1, 3)")],
+)
+```
+
+Callables may run zero, one, or multiple times and must be deterministic,
+side-effect-free, and row-local. They are not supported for general
+source-target merges.
+
**Parameters:**
- `source`: a `ray.data.Dataset`, `pyarrow.Table`, `pandas.DataFrame`, or a
Paimon table identifier string. When a string is passed, it reads the table
from the same `catalog_options` at the latest snapshot.
- `on`: key columns, or `{target_col: source_col}` for renamed keys.
+- `read_columns`: columns passed to callable self-merge assignments. Required
+ when an update mapping contains a callable; otherwise it must be omitted.
- `num_partitions`: shuffle parallelism for the join and the write; defaults to
`max(1, cluster_cpus * 2)`. Raise it for large merges on big clusters.
- `ray_remote_args`: Ray remote options applied to the merge's map/group
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 8695c07d77..3f8e3f3d16 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -65,6 +65,7 @@ class _PrepareCtx:
catalog_options: Dict[str, str]
is_self_merge: bool = False
self_merge_scan_predicate: Optional[Predicate] = None
+ read_columns: Tuple[str, ...] = ()
def merge_into(
@@ -78,6 +79,7 @@ def merge_into(
num_partitions: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
concurrency: Optional[int] = None,
+ read_columns: Optional[Sequence[str]] = None,
) -> Dict[str, int]:
_require_ray_join()
num_partitions = _resolve_num_partitions(num_partitions)
@@ -85,6 +87,7 @@ def merge_into(
table, source_ds, matched_specs, not_matched_specs, ctx = _prepare(
target, source, catalog_options,
list(when_matched), list(when_not_matched), on,
+ read_columns,
)
base_snapshot = table.snapshot_manager().get_latest_snapshot()
@@ -100,7 +103,10 @@ def merge_into(
)
-def _prepare(target, source, catalog_options, when_matched, when_not_matched,
on):
+def _prepare(
+ target, source, catalog_options, when_matched, when_not_matched, on,
+ read_columns=None,
+):
if not when_matched and not when_not_matched:
raise ValueError(
"At least one of when_matched or when_not_matched must be
non-empty."
@@ -139,12 +145,18 @@ def _prepare(target, source, catalog_options,
when_matched, when_not_matched, on
full_target_field_names = list(table.field_names)
settable_field_names = list(full_target_field_names)
on_map = dict(zip(target_on_cols, source_on_cols))
+ is_self_merge = _is_self_merge(
+ target, source, target_on_cols, source_on_cols
+ )
matched_specs = []
for c in when_matched:
spec = {}
if not c.delete:
spec = _normalize_set_spec(
- c.update, settable_field_names, on_map,
+ c.update,
+ settable_field_names,
+ on_map,
+ allow_callables=is_self_merge,
)
matched_specs.append(
_NormalizedClause(
@@ -193,13 +205,29 @@ def _prepare(target, source, catalog_options,
when_matched, when_not_matched, on
_NormalizedClause(spec=spec, condition=c.condition)
)
- is_self_merge = _is_self_merge(target, source, target_on_cols,
source_on_cols)
if is_self_merge and not_matched_specs:
raise ValueError(
"Self-merge (source == target with ON _ROW_ID) does not "
"support WHEN NOT MATCHED clauses."
)
+ read_columns = tuple(dict.fromkeys(read_columns or ()))
+ has_callable = any(
+ callable(value) and not isinstance(value, type)
+ for clause in matched_specs
+ for value in clause.spec.values()
+ )
+ if read_columns and not has_callable:
+ raise ValueError("read_columns requires a callable SET value.")
+ if has_callable:
+ if not read_columns:
+ raise ValueError("Callable SET values require read_columns.")
+ for col in read_columns:
+ if col not in full_target_field_names:
+ raise ValueError(
+ f"Read column {col!r} is not in target '{target}'."
+ )
+
if is_self_merge:
source_ds = None
source_col_names = set(full_target_field_names) | set(source_on_cols)
@@ -280,6 +308,7 @@ def _prepare(target, source, catalog_options, when_matched,
when_not_matched, on
catalog_options=catalog_options,
is_self_merge=is_self_merge,
self_merge_scan_predicate=self_merge_scan_predicate,
+ read_columns=read_columns,
)
return table, source_ds, matched_specs, not_matched_specs, ctx
@@ -321,6 +350,7 @@ def _build_datasets(
resolve_target_projection=_resolve_target_projection,
snapshot_id=base_snapshot_id,
scan_predicate=ctx.self_merge_scan_predicate,
+ read_columns=ctx.read_columns,
ray_remote_args=ray_remote_args,
)
if any(c.delete for c in matched_specs):
@@ -588,6 +618,7 @@ def _normalize_set_spec(
target_field_names: Sequence[str],
on_map: Optional[Mapping[str, str]] = None,
allow_target_refs: bool = True,
+ allow_callables: bool = False,
) -> Dict[str, Any]:
on_map = on_map or {}
if spec == "*":
@@ -610,9 +641,13 @@ def _normalize_set_spec(
result: Dict[str, Any] = {}
for key, val in spec.items():
if callable(val) and not isinstance(val, type):
+ if allow_callables:
+ result[key] = val
+ continue
raise TypeError(
"SET values must be source_col(), target_col(), "
- "lit(), or literals, not callables"
+ "lit(), or literals; callables are only supported "
+ "for self-merge"
)
if isinstance(val, SourceColumnRef):
result[key] = val
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 863dfd5984..d7e381adc4 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -73,6 +73,7 @@ def _build_matched_transform(
update_cols: List[str],
row_id_name: str,
update_schema: pa.Schema,
+ callable_input_columns: Optional[Sequence[str]] = None,
):
prepared_clauses = []
for clause in clauses:
@@ -105,10 +106,20 @@ def _build_matched_transform(
if matched.num_rows == 0:
continue
if not is_delete:
+ callable_input = None
+ if (callable_input_columns is not None
+ and any(callable(value) and not isinstance(value, type)
+ for value in spec.values())):
+ callable_input = pa.Table.from_arrays(
+ [matched.column(f"t.{col}")
+ for col in callable_input_columns],
+ names=list(callable_input_columns),
+ )
parts.append(vectorized_matched_transform(
matched, spec, on_pairs,
update_cols, row_id_name,
update_schema,
+ callable_input=callable_input,
))
if rewritten is not None and matched.num_rows < remaining.num_rows:
not_cond = f"COALESCE(NOT ({rewritten}), TRUE)"
@@ -189,6 +200,7 @@ def build_self_merge_update_ds(
resolve_target_projection,
snapshot_id: Optional[int] = None,
scan_predicate=None,
+ read_columns: Sequence[str] = (),
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Tuple:
from pypaimon.ray.ray_paimon import read_paimon
@@ -198,6 +210,7 @@ def build_self_merge_update_ds(
needed_cols = set(resolve_target_projection(
clauses, [row_id_name], update_cols, target_field_names,
))
+ needed_cols.update(read_columns)
for clause in clauses:
for value in clause.spec.values():
if isinstance(value, SourceColumnRef):
@@ -261,6 +274,10 @@ def build_self_merge_update_ds(
update_cols=list(update_cols),
row_id_name=row_id_name,
update_schema=update_schema,
+ callable_input_columns=(
+ list(read_columns) + [row_id_name]
+ if read_columns else None
+ ),
)
return aliased.map_batches(_transform, **_map_kwargs(ray_remote_args))
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
index 80ae82732b..ad29368e51 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_transform.py
@@ -101,6 +101,7 @@ def vectorized_matched_transform(
update_cols: Sequence[str],
row_id_name: str,
update_schema: pa.Schema,
+ callable_input: Optional[pa.Table] = None,
) -> pa.Table:
available = set(batch.schema.names)
arrays: list = [batch.column(f"t.{row_id_name}")]
@@ -109,7 +110,8 @@ def vectorized_matched_transform(
if col in spec:
arrays.append(
_resolve_spec_array(
- spec[col], batch, available, on_pairs, out_type
+ spec[col], batch, available, on_pairs, out_type,
+ callable_input=callable_input,
)
)
else:
@@ -173,7 +175,33 @@ def _resolve_spec_array(
available: set,
on_pairs: Sequence[Tuple[str, str]],
out_type: pa.DataType,
+ callable_input: Optional[pa.Table] = None,
):
+ if callable(val) and not isinstance(val, type):
+ if callable_input is None:
+ raise TypeError(
+ "Callable SET values are only supported for self-merge."
+ )
+ result = val(callable_input)
+ if not isinstance(result, (pa.Array, pa.ChunkedArray)):
+ raise ValueError(
+ "Callable SET values must return a pyarrow.Array or "
+ "pyarrow.ChunkedArray."
+ )
+ if len(result) != batch.num_rows:
+ raise ValueError(
+ "Callable SET result length must match matched row count: "
+ f"{len(result)} != {batch.num_rows}."
+ )
+ if result.type != out_type:
+ if isinstance(result, pa.ChunkedArray):
+ result = pa.chunked_array(
+ [chunk.cast(out_type) for chunk in result.chunks],
+ type=out_type,
+ )
+ else:
+ result = result.cast(out_type)
+ return result
if isinstance(val, LiteralValue):
return pa.array([val.value] * batch.num_rows, type=out_type)
if isinstance(val, SourceColumnRef):
diff --git a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
index 70a0a0d189..e8a66a6061 100644
--- a/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/ray_data_evolution_merge_into_test.py
@@ -25,6 +25,7 @@ import uuid
from unittest.mock import Mock, patch
import pyarrow as pa
+import pyarrow.compute as pc
import ray
from pypaimon import CatalogFactory, Schema
@@ -2037,6 +2038,253 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
self.assertEqual(out['age'], [99, 99, 99])
self.assertEqual(out['name'], ['a', 'b', 'c'])
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_callable_assignment(self):
+ from pypaimon.ray.ray_paimon import read_paimon as real_read_paimon
+
+ target = self._create_table()
+ self._write(
+ target,
+ pa.Table.from_pydict(
+ {
+ 'id': pa.array([1, 2, 3], type=pa.int32()),
+ 'name': ['a', 'b', 'c'],
+ 'age': pa.array([10, 20, 30], type=pa.int32()),
+ },
+ schema=self.pa_schema,
+ ),
+ )
+
+ def increment_age(rows):
+ if rows.column_names != ['age', '_ROW_ID']:
+ raise AssertionError(rows.column_names)
+ return pc.add(rows['age'], 1)
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ wraps=real_read_paimon,
+ ) as mock_read:
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ read_columns=['age'],
+ when_matched=[WhenMatched.update(
+ {
+ 'age': increment_age,
+ 'name': lit('updated'),
+ },
+ condition='t.id IN (1, 3)',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 2)
+ self.assertEqual(
+ self._read_sorted(target),
+ {
+ 'id': [1, 2, 3],
+ 'name': ['updated', 'b', 'updated'],
+ 'age': [11, 20, 31],
+ },
+ )
+ self.assertEqual(
+ mock_read.call_args[1]['projection'], ['_ROW_ID', 'id', 'age']
+ )
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_callable_updates_variant_path(self):
+ from pypaimon.data.generic_variant import GenericVariant
+ from pypaimon.data.variant_path import variant_get, variant_replace
+
+ variant_type = pa.struct([
+ pa.field('value', pa.binary(), nullable=False),
+ pa.field('metadata', pa.binary(), nullable=False),
+ ])
+ pa_schema = pa.schema([
+ ('id', pa.int32()),
+ ('payload', variant_type),
+ ])
+ target = f'default.tbl_{uuid.uuid4().hex[:8]}'
+ self.catalog.create_table(
+ target,
+ Schema.from_pyarrow_schema(pa_schema, options=self.de_options),
+ False,
+ )
+ payload = GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'value': 1.5}),
+ GenericVariant.from_python({'value': 2.5}),
+ ])
+ self._write(target, pa.table({
+ 'id': pa.array([1, 2], type=pa.int32()),
+ 'payload': payload,
+ }, schema=pa_schema))
+
+ def negate_value(rows):
+ values = variant_get(
+ rows['payload'], '$.value', pa.float64()
+ )
+ return variant_replace(
+ rows['payload'], '$.value', pc.negate(values), strict=True
+ )
+
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ read_columns=['payload'],
+ when_matched=[WhenMatched.update(
+ {'payload': negate_value}, condition='t.id = 2',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 1)
+ output = self._read_sorted(target)['payload']
+ decoded = [
+ GenericVariant.from_arrow_struct(value).to_python()
+ for value in output
+ ]
+ self.assertEqual(decoded, [{'value': 1.5}, {'value': -2.5}])
+
+ @unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
+ def test_self_merge_no_match_does_not_invoke_callable(self):
+ target = self._create_table()
+ self._write(target, self._source())
+
+ def should_not_run(_rows):
+ raise AssertionError("Callable must not run without matched rows")
+
+ result = merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ read_columns=['age'],
+ when_matched=[WhenMatched.update(
+ {'age': should_not_run}, condition='t.id = 99',
+ )],
+ num_partitions=_TEST_NUM_PARTITIONS,
+ )
+
+ self.assertEqual(result['num_matched'], 0)
+ self.assertEqual(self._read_sorted(target)['age'], [10])
+
+ def test_self_merge_callable_validation(self):
+ target = self._create_table()
+
+ with self.assertRaisesRegex(
+ ValueError, 'Callable SET values require read_columns'):
+ merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ when_matched=[WhenMatched.update({
+ 'age': lambda rows: rows['age'],
+ })],
+ )
+
+ with self.assertRaisesRegex(
+ ValueError, 'read_columns requires a callable SET value'):
+ merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ read_columns=['age'],
+ when_matched=[WhenMatched.update({'age': lit(99)})],
+ )
+
+ with self.assertRaisesRegex(
+ ValueError, "Read column 'missing' is not in target"):
+ merge_into(
+ target=target,
+ source=target,
+ catalog_options=self.catalog_options,
+ on=['_ROW_ID'],
+ read_columns=['missing'],
+ when_matched=[WhenMatched.update({
+ 'age': lambda rows: rows['missing'],
+ })],
+ )
+
+ source = self._create_table()
+ with self.assertRaisesRegex(
+ TypeError, 'callables are only supported for self-merge'):
+ merge_into(
+ target=target,
+ source=source,
+ catalog_options=self.catalog_options,
+ on=['id'],
+ read_columns=['age'],
+ when_matched=[WhenMatched.update({
+ 'age': lambda rows: rows['age'],
+ })],
+ )
+
+ def test_self_merge_callable_rejects_invalid_result(self):
+ from pypaimon.ray.data_evolution_merge_transform import (
+ _resolve_spec_array,
+ )
+
+ batch = pa.table({'t.age': pa.array([10], type=pa.int32())})
+ callable_input = pa.table({
+ 'age': pa.array([10], type=pa.int32()),
+ })
+
+ with self.assertRaisesRegex(
+ ValueError, 'must return a pyarrow.Array'):
+ _resolve_spec_array(
+ lambda rows: rows['age'].to_pylist(),
+ batch,
+ set(batch.column_names),
+ [],
+ pa.int32(),
+ callable_input=callable_input,
+ )
+
+ with self.assertRaisesRegex(
+ ValueError, 'length must match matched row count'):
+ _resolve_spec_array(
+ lambda _rows: pa.array([], type=pa.int32()),
+ batch,
+ set(batch.column_names),
+ [],
+ pa.int32(),
+ callable_input=callable_input,
+ )
+
+ def test_self_merge_callable_preserves_chunked_result(self):
+ from pypaimon.ray.data_evolution_merge_transform import (
+ _resolve_spec_array,
+ )
+
+ batch = pa.table({'t.age': pa.array([10, 20], type=pa.int32())})
+ callable_input = pa.table({
+ 'age': pa.array([10, 20], type=pa.int32()),
+ })
+
+ def chunked_result(_rows):
+ return pa.chunked_array([
+ pa.array([10], type=pa.int64()),
+ pa.array([20], type=pa.int64()),
+ ])
+
+ result = _resolve_spec_array(
+ chunked_result,
+ batch,
+ set(batch.column_names),
+ [],
+ pa.int32(),
+ callable_input=callable_input,
+ )
+ self.assertIsInstance(result, pa.ChunkedArray)
+ self.assertEqual(result.num_chunks, 2)
+ self.assertEqual(result.type, pa.int32())
+
@unittest.skipIf(_SKIP_CONDITION, _SKIP_REASON)
def test_self_merge_condition_pushes_down_predicate(self):
from pypaimon.common.options.core_options import (