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 8871cc4adf [python][ray] Adapt default partitions to input size (#9357)
8871cc4adf is described below
commit 8871cc4adfa80d5b0258362693eae3cebe4b7264
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Aug 27 14:23:56 2026 +0800
[python][ray] Adapt default partitions to input size (#9357)
## What changed
- Preserve an explicitly configured `num_partitions`.
- Size general MERGE from reliable in-memory source metadata. Unknown
inputs, including Paimon scans, use Ray's hash-shuffle default; a
nonempty target keeps it as a lower bound.
- Read block sizing and shuffle defaults from the source Dataset's
sealed `DataContext`.
- Handle Ray's property, callable, and boolean cardinality metadata
while keeping legacy `MapBatches` conservative.
- Treat never-written and truncated targets as empty, avoid target
joins, and repartition inserts to the resolved write parallelism.
- For row-ID update/read, combine reliable source metadata with
target-file fan-out.
- Keep self-merge on its previous CPU-based default.
- Avoid a separate target scan for partition estimation.
## Why
The previous default created 640 shuffle partitions on a 320-CPU cluster
even for small inputs. This follows Ray's own hash-shuffle default for
unknown sizes while retaining size-based scaling when in-memory metadata
is reliable.
Paimon split sizes are compressed file bytes, not Ray block memory
bytes, so they are not used for partition sizing.
## Validation
- Real Paimon source MERGE with highly compressed data: 200 partitions
instead of 640 or 1 on a mocked 320-CPU cluster.
- Truncated target with explicit parallelism: three materialized write
blocks and 30 inserted rows.
- `read_paimon(filter) -> map_batches -> update_by_row_id(None)`: 39
matching rows across 201 target files request 200 instead of 640.
- Dataset sealed at 128 MiB blocks still sizes a 512 MiB input to four
after the global context changes to 1 GiB.
- Cardinality compatibility verified on Ray 2.44, 2.50.1, 2.53, 2.54,
and 2.57; Ray 2.44 and 2.53 are covered by CI.
- Latest-master merge tree: 55 partition/update/read tests and 123
non-BLOB MERGE tests passed, with 18 subtests.
- Python 3.6 `py_compile`, flake8, and `git diff --check` passed.
---
.github/workflows/paimon-python-checks.yml | 6 +
docs/docs/pypaimon/ray-data.md | 17 +-
.../pypaimon/ray/data_evolution_merge_into.py | 113 +++++--
.../pypaimon/ray/data_evolution_merge_join.py | 65 ++++-
paimon-python/pypaimon/ray/partitioning.py | 177 +++++++++++
paimon-python/pypaimon/ray/read_by_row_id.py | 17 +-
paimon-python/pypaimon/ray/update_by_row_id.py | 17 +-
.../tests/ray_data_evolution_merge_into_test.py | 295 ++++++++++++++++++-
.../pypaimon/tests/ray_partitioning_test.py | 324 +++++++++++++++++++++
.../pypaimon/tests/ray_read_by_row_id_test.py | 12 +-
.../pypaimon/tests/ray_update_by_row_id_test.py | 213 +++++++++++++-
11 files changed, 1213 insertions(+), 43 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index d2a8ed781c..c66101d8e6 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -309,6 +309,12 @@ jobs:
if [ "$ray_version" = "2.50.1" ]; then
tests="$tests
pypaimon/tests/ray_update_by_row_id_test.py::RayUpdateByRowIdTest::test_empty_dataset_after_transform_is_noop"
fi
+ if [ "$ray_version" = "2.44.0" ]; then
+ tests="$tests
pypaimon/tests/ray_partitioning_test.py::RayPartitioningTest::test_ray_legacy_map_batches_cardinality_is_unknown"
+ fi
+ if [ "$ray_version" = "2.53.0" ]; then
+ tests="$tests
pypaimon/tests/ray_partitioning_test.py::RayPartitioningTest::test_ray_metadata_respects_transform_cardinality"
+ fi
python -m pytest $tests -v --tb=short || {
echo "Tests failed for Ray $ray_version"; python -m pip
uninstall -y ray; exit 1;
}
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index 1f9194c92c..af6386e009 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -544,8 +544,11 @@ source-target merges.
- `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.
+- `num_partitions`: shuffle parallelism for the join and the write. When input
+ in-memory byte-size metadata is reliable, the default targets Ray's maximum
+ block size. Otherwise it uses Ray's hash-shuffle default. A nonempty target
+ keeps that default as a lower bound, and cluster CPUs cap the result.
Self-merge
+ keeps its CPU-based default. Set it explicitly to override the default.
- `ray_remote_args`: Ray remote options applied to the merge's map/group
tasks (update/delete transform, group write, insert transform).
- `concurrency`: scheduling for the insert sink.
@@ -595,8 +598,9 @@ print(metrics) # {"num_updated": 50}
values are cast to the target column types. A table-name source is not
accepted: a
table's system `_ROW_ID` is its own and cannot address the target's rows.
- `update_cols`: the non-blob columns to overwrite. Must be non-empty.
-- `num_partitions`: parallelism for grouping the update rows by target file;
- defaults to `max(1, cluster_cpus * 2)`.
+- `num_partitions`: parallelism for grouping the update rows by target file.
+ The default uses reliable source metadata when available; otherwise it uses
+ Ray's hash-shuffle default. Target file count and cluster CPUs bound the
result.
- `ray_remote_args`: Ray remote options applied to the update tasks.
**Returns:** `{"num_updated": <rows>}`.
@@ -646,8 +650,9 @@ ds = read_by_row_id(
(resolved later with `map_with_blobs`), or `scan.snapshot-id` /
`scan.tag-name` to read a
specific snapshot. Options that flip table invariants
(`data-evolution.enabled`,
`row-tracking.enabled`, `deletion-vectors.enabled`) are rejected.
-- `num_partitions`: parallelism for grouping the row ids by target file;
defaults to
- `max(1, cluster_cpus * 2)`.
+- `num_partitions`: parallelism for grouping the row ids by target file. The
+ default uses reliable source metadata when available; otherwise it uses Ray's
+ hash-shuffle default. Target file count and cluster CPUs bound the result.
- `ray_remote_args`: Ray remote options applied to the read tasks.
**Returns:** a `ray.data.Dataset` of `(*projection, _ROW_ID)`.
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index e0544f1890..3a280dd028 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -27,6 +27,7 @@ import pyarrow as pa
from pypaimon.common.predicate import Predicate
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.ray.data_evolution_merge_join import (
+ _resolve_matched_num_partitions,
_resolve_source_projection,
build_matched_delete_ds,
build_matched_update_ds,
@@ -49,6 +50,11 @@ from pypaimon.ray.data_evolution_merge_transform import (
WhenNotMatched,
_NormalizedClause,
)
+from pypaimon.ray.partitioning import (
+ _default_hash_shuffle_parallelism,
+ _estimate_dataset_size_bytes,
+ _resolve_num_partitions,
+)
__all__ = ["merge_into", "WhenMatched", "WhenNotMatched"]
@@ -84,7 +90,7 @@ def merge_into(
read_columns: Optional[Sequence[str]] = None,
) -> Dict[str, int]:
_require_ray_join()
- num_partitions = _resolve_num_partitions(num_partitions)
+ requested_num_partitions = num_partitions
table, source_ds, matched_specs, not_matched_specs, ctx = _prepare(
target, source, catalog_options,
@@ -92,16 +98,61 @@ def merge_into(
read_columns,
)
base_snapshot = table.snapshot_manager().get_latest_snapshot()
+ target_empty = _is_target_empty(base_snapshot)
+ estimated_size_bytes = None
+ if num_partitions is None:
+ estimated_size_bytes = _estimate_merge_input_size_bytes(
+ source_ds, ctx,
+ )
+ min_partitions = 1
+ unknown_num_partitions = None
+ data_context = (
+ None
+ if ctx.is_self_merge
+ else getattr(source_ds, "context", None)
+ )
+ if num_partitions is None and not ctx.is_self_merge:
+ unknown_num_partitions = _default_hash_shuffle_parallelism(
+ data_context
+ )
+ if not target_empty:
+ min_partitions = unknown_num_partitions
+ source_num_partitions = _resolve_num_partitions(
+ num_partitions,
+ estimated_size_bytes,
+ min_partitions=min_partitions,
+ unknown_num_partitions=unknown_num_partitions,
+ data_context=data_context,
+ )
update_ds, delete_ds, insert_ds, update_cols_union = _build_datasets(
table, target, source_ds, matched_specs, not_matched_specs,
- ctx, base_snapshot, num_partitions, ray_remote_args,
+ ctx, base_snapshot, source_num_partitions, ray_remote_args,
+ requested_num_partitions=requested_num_partitions,
+ estimated_size_bytes=estimated_size_bytes,
)
+ update_num_partitions = None
+ delete_num_partitions = None
+ if not ctx.is_self_merge:
+ if update_ds is not None:
+ update_num_partitions = _resolve_matched_num_partitions(
+ requested_num_partitions,
+ estimated_size_bytes,
+ update_ds,
+ )
+ if delete_ds is not None:
+ delete_num_partitions = _resolve_matched_num_partitions(
+ requested_num_partitions,
+ estimated_size_bytes,
+ delete_ds,
+ )
return _execute_and_commit(
table, update_ds, delete_ds, insert_ds, update_cols_union,
- base_snapshot, num_partitions,
+ base_snapshot, source_num_partitions,
ray_remote_args, concurrency,
+ update_num_partitions=update_num_partitions,
+ delete_num_partitions=delete_num_partitions,
)
@@ -326,7 +377,9 @@ def _is_self_merge(target, source, target_on_cols,
source_on_cols) -> bool:
def _build_datasets(
table, target, source_ds, matched_specs, not_matched_specs,
- ctx: "_PrepareCtx", base_snapshot, num_partitions, ray_remote_args,
+ ctx: "_PrepareCtx", base_snapshot, source_num_partitions, ray_remote_args,
+ requested_num_partitions: Optional[int] = None,
+ estimated_size_bytes: Optional[int] = None,
):
# Pin every target read to base_snapshot so all branches see the same
# snapshot the caller observed; otherwise concurrent commits in between
@@ -337,9 +390,10 @@ def _build_datasets(
delete_ds = None
insert_ds = None
update_cols_union: List[str] = []
+ target_empty = _is_target_empty(base_snapshot)
if ctx.is_self_merge:
- if matched_specs and base_snapshot is not None:
+ if matched_specs and not target_empty:
update_cols_union = _union_update_cols(matched_specs)
if update_cols_union:
update_ds = build_self_merge_update_plan(
@@ -369,7 +423,7 @@ def _build_datasets(
# Mirror Spark: matched/not-matched run as two independent joins
# (inner / left_anti). One unified left_outer join would force
# joined.materialize() to feed both branches, which can OOM on large
merges.
- if matched_specs and base_snapshot is not None:
+ if matched_specs and not target_empty:
update_cols_union = _union_update_cols(matched_specs)
if update_cols_union:
update_ds = build_matched_update_ds(
@@ -382,7 +436,8 @@ def _build_datasets(
target_pa_schema=ctx.update_pa_schema,
update_cols=update_cols_union,
catalog_options=ctx.catalog_options,
- num_partitions=num_partitions,
+ num_partitions=requested_num_partitions,
+ estimated_size_bytes=estimated_size_bytes,
resolve_target_projection=_resolve_target_projection,
snapshot_id=base_snapshot_id,
ray_remote_args=ray_remote_args,
@@ -396,7 +451,8 @@ def _build_datasets(
clauses=matched_specs,
target_field_names=ctx.settable_field_names,
catalog_options=ctx.catalog_options,
- num_partitions=num_partitions,
+ num_partitions=requested_num_partitions,
+ estimated_size_bytes=estimated_size_bytes,
resolve_target_projection=_resolve_target_projection,
snapshot_id=base_snapshot_id,
ray_remote_args=ray_remote_args,
@@ -412,9 +468,9 @@ def _build_datasets(
target_field_names=ctx.full_target_field_names,
target_pa_schema=ctx.full_pa_schema,
catalog_options=ctx.catalog_options,
- num_partitions=num_partitions,
+ num_partitions=source_num_partitions,
snapshot_id=base_snapshot_id,
- target_empty=base_snapshot is None,
+ target_empty=target_empty,
ray_remote_args=ray_remote_args,
)
@@ -425,6 +481,8 @@ def _execute_and_commit(
table, update_ds, delete_ds, insert_ds, update_cols_union,
base_snapshot, num_partitions,
ray_remote_args, concurrency,
+ update_num_partitions=None,
+ delete_num_partitions=None,
):
collect_action_row_ids = update_ds is not None and delete_ds is not None
commit_messages: list = []
@@ -438,6 +496,16 @@ def _execute_and_commit(
num_inserted = 0
insert_msgs: list = []
self_merge_update = isinstance(update_ds, _SelfMergeUpdatePlan)
+ update_num_partitions = (
+ num_partitions
+ if update_num_partitions is None
+ else update_num_partitions
+ )
+ delete_num_partitions = (
+ num_partitions
+ if delete_num_partitions is None
+ else delete_num_partitions
+ )
try:
if update_ds is not None:
@@ -445,7 +513,7 @@ def _execute_and_commit(
update_msgs, num_updated, update_row_ids = (
distributed_self_merge_update_apply(
update_ds,
- num_partitions=num_partitions,
+ num_partitions=update_num_partitions,
ray_remote_args=ray_remote_args,
collect_row_ids=collect_action_row_ids,
)
@@ -454,7 +522,7 @@ def _execute_and_commit(
update_msgs, num_updated, update_row_ids = (
distributed_update_apply(
update_ds, table, update_cols_union,
- num_partitions=num_partitions,
+ num_partitions=update_num_partitions,
ray_remote_args=ray_remote_args,
base_snapshot_id=(
base_snapshot.id
@@ -468,7 +536,7 @@ def _execute_and_commit(
if delete_ds is not None:
delete_msgs, num_deleted, delete_row_ids =
distributed_delete_apply(
delete_ds, table,
- num_partitions=num_partitions,
+ num_partitions=delete_num_partitions,
ray_remote_args=ray_remote_args,
base_snapshot_id=(
base_snapshot.id
@@ -545,16 +613,17 @@ def _normalize_on(on: OnSpec) -> Tuple[List[str],
List[str]]:
return target_cols, source_cols
-def _resolve_num_partitions(num_partitions: Optional[int]) -> int:
- if num_partitions is not None:
- return num_partitions
- try:
- import ray
+def _estimate_merge_input_size_bytes(
+ source_ds,
+ ctx: "_PrepareCtx",
+) -> Optional[int]:
+ if ctx.is_self_merge:
+ return None
+ return _estimate_dataset_size_bytes(source_ds)
+
- cpus = int(ray.cluster_resources().get("CPU", 4))
- return max(1, cpus * 2)
- except Exception:
- return 4
+def _is_target_empty(snapshot) -> bool:
+ return snapshot is None or snapshot.total_record_count == 0
def _require_ray_join() -> None:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index af56d7ca30..5cb42fbf49 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -31,6 +31,11 @@ from pypaimon.ray.data_evolution_merge_transform import (
vectorized_insert_transform,
vectorized_matched_transform,
)
+from pypaimon.ray.partitioning import (
+ _default_hash_shuffle_parallelism,
+ _resolve_num_partitions,
+ _resolve_row_id_num_partitions,
+)
def _map_kwargs(
@@ -45,6 +50,26 @@ def _map_kwargs(
return kwargs
+def _resolve_matched_num_partitions(
+ num_partitions: Optional[int],
+ estimated_size_bytes: Optional[int],
+ target_ds,
+) -> int:
+ """Resolve a target-left join from the context sealed on its left input."""
+ if num_partitions is not None:
+ return num_partitions
+
+ data_context = getattr(target_ds, "context", None)
+ default_shuffle = _default_hash_shuffle_parallelism(data_context)
+ return _resolve_num_partitions(
+ num_partitions,
+ estimated_size_bytes,
+ min_partitions=default_shuffle,
+ unknown_num_partitions=default_shuffle,
+ data_context=data_context,
+ )
+
+
@dataclass(frozen=True)
class _SelfMergeUpdatePlan:
"""Pinned target file groups for self-merge update execution."""
@@ -543,8 +568,9 @@ def build_matched_update_ds(
target_pa_schema: pa.Schema,
update_cols: Sequence[str],
catalog_options: Dict[str, str],
- num_partitions: int,
+ num_partitions: Optional[int],
resolve_target_projection,
+ estimated_size_bytes: Optional[int] = None,
snapshot_id: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Tuple:
@@ -566,6 +592,9 @@ def build_matched_update_ds(
target_renamed = target_ds.rename_columns(
{c: f"t.{c}" for c in target_ds.schema().names}
)
+ num_partitions = _resolve_matched_num_partitions(
+ num_partitions, estimated_size_bytes, target_renamed,
+ )
source_cols = _resolve_source_projection(
clauses, source_on, source_ds.schema().names,
)
@@ -602,8 +631,9 @@ def build_matched_delete_ds(
clauses: List[_NormalizedClause],
target_field_names: Sequence[str],
catalog_options: Dict[str, str],
- num_partitions: int,
+ num_partitions: Optional[int],
resolve_target_projection,
+ estimated_size_bytes: Optional[int] = None,
snapshot_id: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Tuple:
@@ -628,6 +658,9 @@ def build_matched_delete_ds(
target_renamed = target_ds.rename_columns(
{c: f"t.{c}" for c in target_ds.schema().names}
)
+ num_partitions = _resolve_matched_num_partitions(
+ num_partitions, estimated_size_bytes, target_renamed,
+ )
source_cols = list(source_ds.schema().names)
source_renamed = source_ds.rename_columns(
{c: f"s.{c}" for c in source_cols}
@@ -655,10 +688,13 @@ def distributed_update_apply(
table,
write_update_cols: Sequence[str],
*,
- num_partitions: int,
+ num_partitions: Optional[int],
ray_remote_args: Optional[Dict[str, Any]] = None,
base_snapshot_id: Optional[int] = None,
collect_row_ids: bool = False,
+ estimated_size_bytes: Optional[int] = None,
+ estimated_num_rows: Optional[int] = None,
+ data_context=None,
) -> Tuple[list, int, list]:
import numpy as np
import pickle
@@ -697,6 +733,14 @@ def distributed_update_apply(
if not sorted_first_row_ids:
return [], 0, []
+ num_partitions = _resolve_row_id_num_partitions(
+ num_partitions,
+ estimated_size_bytes,
+ estimated_num_rows,
+ len(sorted_first_row_ids),
+ data_context=data_context,
+ )
+
# Pin commit-time conflict check to the snapshot the join was built on,
# so concurrent commits between read and planner are detected.
check_from_snapshot = (
@@ -858,9 +902,12 @@ def distributed_read_by_row_id(
table,
projection: Sequence[str],
*,
- num_partitions: int,
+ num_partitions: Optional[int],
ray_remote_args: Optional[Dict[str, Any]] = None,
base_snapshot_id: Optional[int] = None,
+ estimated_size_bytes: Optional[int] = None,
+ estimated_num_rows: Optional[int] = None,
+ data_context=None,
):
"""Read ``projection`` for the ``_ROW_ID``s in ``row_ids_ds``, routing
each to its
owning file and reading only the matched rows via ``IndexedSplit`` slicing
(blob
@@ -902,6 +949,14 @@ def distributed_read_by_row_id(
if not sorted_first_row_ids:
return None
+ num_partitions = _resolve_row_id_num_partitions(
+ num_partitions,
+ estimated_size_bytes,
+ estimated_num_rows,
+ len(sorted_first_row_ids),
+ data_context=data_context,
+ )
+
precomputed_info_ref = ray.put(planner._snapshot_files_info())
frid_col = "_FIRST_ROW_ID"
sorted_arr = np.asarray(sorted_first_row_ids, dtype=np.int64)
@@ -1143,7 +1198,7 @@ def build_not_matched_insert_ds(
)
if target_empty:
- unmatched = source_renamed
+ unmatched = source_renamed.repartition(num_partitions)
else:
target_ds = read_paimon(
target_identifier, catalog_options,
diff --git a/paimon-python/pypaimon/ray/partitioning.py
b/paimon-python/pypaimon/ray/partitioning.py
new file mode 100644
index 0000000000..935992c30f
--- /dev/null
+++ b/paimon-python/pypaimon/ray/partitioning.py
@@ -0,0 +1,177 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Best-effort partition sizing for PyPaimon Ray operations."""
+
+from typing import Optional
+
+
+def _resolve_num_partitions(
+ num_partitions: Optional[int],
+ estimated_size_bytes: Optional[int] = None,
+ min_partitions: int = 1,
+ unknown_num_partitions: Optional[int] = None,
+ data_context=None,
+) -> int:
+ """Resolve default shuffle partitions from input size and CPU count."""
+ if num_partitions is not None:
+ return num_partitions
+
+ try:
+ import ray
+
+ cpus = int(ray.cluster_resources().get("CPU", 4))
+ max_partitions = max(1, cpus * 2)
+ except Exception:
+ max_partitions = 4
+
+ if estimated_size_bytes is None:
+ if unknown_num_partitions is not None:
+ return min(
+ max_partitions,
+ max(min_partitions, int(unknown_num_partitions)),
+ )
+ return max_partitions
+
+ try:
+ from ray.data.context import DataContext
+
+ context = (
+ data_context
+ if data_context is not None
+ else DataContext.get_current()
+ )
+ target_size_bytes = int(context.target_max_block_size)
+ except Exception:
+ return max_partitions
+
+ if target_size_bytes <= 0:
+ return max_partitions
+ size_partitions = max(
+ 1,
+ (max(0, int(estimated_size_bytes)) + target_size_bytes - 1)
+ // target_size_bytes,
+ )
+ return min(max_partitions, max(min_partitions, size_partitions))
+
+
+def _estimate_dataset_size_bytes(dataset) -> Optional[int]:
+ """Read logical-plan size metadata without executing the Dataset."""
+ return _estimate_dataset_metadata(dataset, "size_bytes")
+
+
+def _estimate_dataset_num_rows(dataset) -> Optional[int]:
+ """Read logical-plan row metadata without executing the Dataset."""
+ return _estimate_dataset_metadata(dataset, "num_rows")
+
+
+def _estimate_dataset_metadata(dataset, field: str) -> Optional[int]:
+ try:
+ operator = getattr(getattr(dataset, "_logical_plan", None), "dag",
None)
+ while operator is not None:
+ infer_metadata = getattr(operator, "infer_metadata", None)
+ can_modify_num_rows = _can_modify_num_rows(operator)
+ if callable(infer_metadata):
+ value = getattr(infer_metadata(), field, None)
+ if (
+ value is not None
+ and int(value) >= 0
+ and not (
+ field == "size_bytes"
+ and can_modify_num_rows is not None
+ )
+ ):
+ return int(value)
+ if field == "size_bytes":
+ return None
+ # Only inherit row count through transforms Ray marks preserving.
+ if can_modify_num_rows is not False:
+ return None
+ dependencies = getattr(operator, "input_dependencies", ())
+ operator = dependencies[0] if len(dependencies) == 1 else None
+ except Exception:
+ pass
+ return None
+
+
+def _can_modify_num_rows(operator) -> Optional[bool]:
+ is_map_batches = type(operator).__name__ == "MapBatches"
+ class_value = getattr(type(operator), "can_modify_num_rows", None)
+ # Ray <= 2.45 exposes a False property even though the UDF may change rows.
+ if is_map_batches and isinstance(class_value, property):
+ return None
+
+ value = getattr(operator, "can_modify_num_rows", None)
+ if not callable(value):
+ return value if isinstance(value, bool) else None
+
+ # Ray 2.50/2.51 exposes a False method without a cardinality flag.
+ if is_map_batches and not hasattr(operator, "_udf_modifying_row_count"):
+ return None
+ try:
+ value = value()
+ except Exception:
+ return None
+ return value if isinstance(value, bool) else None
+
+
+def _default_hash_shuffle_parallelism(data_context=None) -> int:
+ try:
+ from ray.data.context import DataContext
+
+ context = (
+ data_context
+ if data_context is not None
+ else DataContext.get_current()
+ )
+ return max(
+ 1,
+ int(context.default_hash_shuffle_parallelism),
+ )
+ except Exception:
+ return 200
+
+
+def _resolve_row_id_num_partitions(
+ num_partitions: Optional[int],
+ estimated_size_bytes: Optional[int],
+ estimated_num_rows: Optional[int],
+ target_file_count: int,
+ data_context=None,
+) -> int:
+ """Resolve row-ID partitions from input size and target fan-out."""
+ if num_partitions is not None:
+ return num_partitions
+
+ default_shuffle = _default_hash_shuffle_parallelism(data_context)
+
+ possible_groups = max(1, target_file_count)
+ if estimated_num_rows is not None:
+ possible_groups = min(possible_groups, max(1, estimated_num_rows))
+ min_partitions = min(max(1, default_shuffle), possible_groups)
+ if estimated_size_bytes is None:
+ return min(
+ _resolve_num_partitions(None, data_context=data_context),
+ min_partitions,
+ )
+ return _resolve_num_partitions(
+ None,
+ estimated_size_bytes,
+ min_partitions=min_partitions,
+ data_context=data_context,
+ )
diff --git a/paimon-python/pypaimon/ray/read_by_row_id.py
b/paimon-python/pypaimon/ray/read_by_row_id.py
index 7c7a650803..d9358f3723 100644
--- a/paimon-python/pypaimon/ray/read_by_row_id.py
+++ b/paimon-python/pypaimon/ray/read_by_row_id.py
@@ -30,12 +30,15 @@ from pypaimon.ray.data_evolution_merge_into import (
_normalize_source,
_reraise_inner,
_require_ray_join,
- _resolve_num_partitions,
)
from pypaimon.ray.data_evolution_merge_join import (
_read_output_schema,
distributed_read_by_row_id,
)
+from pypaimon.ray.partitioning import (
+ _estimate_dataset_num_rows,
+ _estimate_dataset_size_bytes,
+)
__all__ = ["read_by_row_id"]
@@ -104,8 +107,6 @@ def read_by_row_id(
if not projection:
raise ValueError("projection must be non-empty.")
projection = list(dict.fromkeys(projection))
- num_partitions = _resolve_num_partitions(num_partitions)
-
table = CatalogFactory.create(catalog_options).get_table(target)
if not table.options.data_evolution_enabled():
raise ValueError(
@@ -141,6 +142,13 @@ def read_by_row_id(
"read_by_row_id does not accept a table-name source; pass a
ray.data."
"Dataset / pyarrow.Table / pandas.DataFrame carrying the target
row ids.")
source_ds = _normalize_source(row_ids, catalog_options)
+ estimated_size_bytes = None
+ estimated_num_rows = None
+ data_context = None
+ if num_partitions is None:
+ estimated_size_bytes = _estimate_dataset_size_bytes(source_ds)
+ estimated_num_rows = _estimate_dataset_num_rows(source_ds)
+ data_context = getattr(source_ds, "context", None)
# Only check now if the schema is free; fetching it would execute a lazy
source.
known_schema = source_ds.schema(fetch_if_missing=False)
if known_schema is not None and src_rid_col not in set(known_schema.names):
@@ -185,6 +193,9 @@ def read_by_row_id(
num_partitions=num_partitions,
ray_remote_args=ray_remote_args,
base_snapshot_id=base.id,
+ estimated_size_bytes=estimated_size_bytes,
+ estimated_num_rows=estimated_num_rows,
+ data_context=data_context,
)
except Exception as e:
_reraise_inner(e)
diff --git a/paimon-python/pypaimon/ray/update_by_row_id.py
b/paimon-python/pypaimon/ray/update_by_row_id.py
index bc1b437e8c..58679a8a98 100644
--- a/paimon-python/pypaimon/ray/update_by_row_id.py
+++ b/paimon-python/pypaimon/ray/update_by_row_id.py
@@ -32,10 +32,13 @@ from pypaimon.ray.data_evolution_merge_into import (
_normalize_source,
_reraise_inner,
_require_ray_join,
- _resolve_num_partitions,
)
from pypaimon.ray.data_evolution_merge_join import distributed_update_apply
from pypaimon.ray.data_evolution_merge_transform import build_update_schema
+from pypaimon.ray.partitioning import (
+ _estimate_dataset_num_rows,
+ _estimate_dataset_size_bytes,
+)
from pypaimon.schema.data_types import is_blob_file_field
__all__ = ["update_by_row_id"]
@@ -75,8 +78,6 @@ def update_by_row_id(
if not update_cols:
raise ValueError("update_cols must be non-empty.")
update_cols = list(dict.fromkeys(update_cols)) # de-dup, keep order
- num_partitions = _resolve_num_partitions(num_partitions)
-
table = CatalogFactory.create(catalog_options).get_table(target)
if not table.options.data_evolution_enabled():
raise ValueError(
@@ -114,6 +115,13 @@ def update_by_row_id(
"update_by_row_id does not accept a table-name source; pass a
ray.data."
f"Dataset / pyarrow.Table / pandas.DataFrame carrying the target
{rid}.")
source_ds = _normalize_source(source, catalog_options)
+ estimated_size_bytes = None
+ estimated_num_rows = None
+ data_context = None
+ if num_partitions is None:
+ estimated_size_bytes = _estimate_dataset_size_bytes(source_ds)
+ estimated_num_rows = _estimate_dataset_num_rows(source_ds)
+ data_context = getattr(source_ds, "context", None)
src_cols = set(source_ds.schema().names)
missing = [c for c in [rid] + update_cols if c not in src_cols]
if missing:
@@ -145,6 +153,9 @@ def update_by_row_id(
num_partitions=num_partitions,
ray_remote_args=ray_remote_args,
base_snapshot_id=base.id,
+ estimated_size_bytes=estimated_size_bytes,
+ estimated_num_rows=estimated_num_rows,
+ data_context=data_context,
)
except Exception as e:
_reraise_inner(e)
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 e0a35e1740..8a72558bb7 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
@@ -204,6 +204,140 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
projection=['id', 'name', 'age'],
)
+ def test_paimon_source_does_not_use_compressed_size(self):
+ from pypaimon.ray import data_evolution_merge_into as m
+
+ target = self._create_table()
+ source = self._create_table()
+ self._write(target, self._source(ids=(0,)))
+ value = 'x' * 1_000_000
+ source_data = pa.Table.from_pydict(
+ {
+ 'id': pa.array(list(range(16)), type=pa.int32()),
+ 'name': [value] * 16,
+ 'age': [10] * 16,
+ },
+ schema=self.pa_schema,
+ )
+ self._write(source, source_data)
+ source_table = self.catalog.get_table(source)
+ splits = source_table.new_read_builder().new_scan().plan().splits()
+ compressed_size = sum(split.file_size for split in splits)
+ self.assertLess(compressed_size * 10, source_data.nbytes)
+
+ real_resolve = m._resolve_num_partitions
+ resolved = []
+
+ def capture(*args, **kwargs):
+ result = real_resolve(*args, **kwargs)
+ resolved.append((args, kwargs, result))
+ return result
+
+ with patch(
+ 'ray.cluster_resources', return_value={'CPU': 320},
+ ), patch.object(
+ m, '_resolve_num_partitions', side_effect=capture,
+ ), patch.object(
+ m, '_build_datasets', return_value=(None, None, None, set()),
+ ), patch.object(
+ m, '_execute_and_commit', return_value={},
+ ):
+ merge_into(
+ target=target,
+ source=source,
+ catalog_options=self.catalog_options,
+ on=['id'],
+ when_matched=[WhenMatched.update('*')],
+ )
+
+ self.assertEqual(len(resolved), 1)
+ args, kwargs, result = resolved[0]
+ self.assertEqual(args, (None, None))
+ self.assertIsNotNone(kwargs.pop('data_context'))
+ self.assertEqual(kwargs, {
+ 'min_partitions': 200,
+ 'unknown_num_partitions': 200,
+ })
+ self.assertEqual(result, 200)
+
+ def test_matched_execution_uses_target_context(self):
+ from pypaimon.ray import data_evolution_merge_into as m
+
+ source_context = Mock(
+ target_max_block_size=512,
+ default_hash_shuffle_parallelism=7,
+ )
+ target_context = Mock(
+ target_max_block_size=128,
+ default_hash_shuffle_parallelism=3,
+ )
+ source_ds = Mock(context=source_context)
+ update_ds = Mock(context=target_context)
+ delete_ds = Mock(context=target_context)
+ ctx = Mock(is_self_merge=False)
+ snapshot = Mock(total_record_count=1)
+ table = Mock()
+ table.snapshot_manager().get_latest_snapshot.return_value = snapshot
+
+ with patch.object(
+ m, '_prepare',
+ return_value=(table, source_ds, [], [], ctx),
+ ), patch.object(
+ m, '_estimate_merge_input_size_bytes', return_value=512,
+ ), patch.object(
+ m, '_build_datasets',
+ return_value=(update_ds, delete_ds, None, ['age']),
+ ) as build_datasets, patch.object(
+ m, 'distributed_update_apply', return_value=([], 0, []),
+ ) as update_apply, patch.object(
+ m, 'distributed_delete_apply', return_value=([], 0, []),
+ ) as delete_apply, patch(
+ 'ray.cluster_resources', return_value={'CPU': 320},
+ ):
+ merge_into(
+ target='default.target',
+ source=source_ds,
+ catalog_options={'warehouse': '/tmp/warehouse'},
+ on=['id'],
+ when_matched=[WhenMatched.update('*')],
+ )
+ default_source_partitions = build_datasets.call_args.args[7]
+ default_update_partitions = (
+ update_apply.call_args.kwargs['num_partitions']
+ )
+ default_delete_partitions = (
+ delete_apply.call_args.kwargs['num_partitions']
+ )
+
+ build_datasets.reset_mock()
+ update_apply.reset_mock()
+ delete_apply.reset_mock()
+ merge_into(
+ target='default.target',
+ source=source_ds,
+ catalog_options={'warehouse': '/tmp/warehouse'},
+ on=['id'],
+ when_matched=[WhenMatched.update('*')],
+ num_partitions=11,
+ )
+
+ # The source-left branch would use 7 partitions.
+ self.assertEqual(default_source_partitions, 7)
+ # Both matched results inherit the target-left context: 512 / 128 = 4.
+ self.assertEqual(default_update_partitions, 4)
+ self.assertEqual(default_delete_partitions, 4)
+ # An explicit value still applies to every branch and execution stage.
+ self.assertEqual(build_datasets.call_args.args[7], 11)
+ self.assertEqual(
+ build_datasets.call_args.kwargs['requested_num_partitions'], 11
+ )
+ self.assertEqual(
+ update_apply.call_args.kwargs['num_partitions'], 11
+ )
+ self.assertEqual(
+ delete_apply.call_args.kwargs['num_partitions'], 11
+ )
+
def test_no_clause_raises(self):
target = self._create_table()
with self.assertRaises(ValueError):
@@ -566,6 +700,94 @@ class RayDataEvolutionMergeIntoTest(unittest.TestCase):
self.assertEqual(out['name'], ['a', 'b', 'c'])
self.assertEqual(out['age'], [10, 20, 30])
+ def test_insert_into_truncated_target_uses_empty_fast_path(self):
+ from pypaimon.ray import data_evolution_merge_into as m
+
+ target = self._create_table()
+ self._write(target, self._source(ids=(0,)))
+ table = self.catalog.get_table(target)
+ commit = table.new_batch_write_builder().new_commit()
+ commit.truncate_table()
+ commit.close()
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ self.assertIsNotNone(snapshot)
+ self.assertEqual(snapshot.total_record_count, 0)
+
+ real_resolve = m._resolve_num_partitions
+ resolved = []
+
+ def capture(*args, **kwargs):
+ result = real_resolve(*args, **kwargs)
+ resolved.append((args, kwargs, result))
+ return result
+
+ with patch(
+ 'ray.cluster_resources', return_value={'CPU': 320},
+ ), patch.object(
+ m, '_resolve_num_partitions', side_effect=capture,
+ ), patch.object(
+ ray.data.Dataset,
+ 'join',
+ side_effect=AssertionError('empty target must not be joined'),
+ ):
+ metrics = merge_into(
+ target=target,
+ source=self._source(ids=(1,)),
+ catalog_options=self.catalog_options,
+ on=['id'],
+ when_matched=[WhenMatched.update('*')],
+ when_not_matched=[WhenNotMatched(insert='*')],
+ )
+
+ self.assertEqual(metrics['num_inserted'], 1)
+ self.assertEqual(self._read_sorted(target)['id'], [1])
+ self.assertEqual(len(resolved), 1)
+ args, kwargs, result = resolved[0]
+ self.assertIsNone(args[0])
+ self.assertGreater(args[1], 0)
+ self.assertIsNotNone(kwargs.pop('data_context'))
+ self.assertEqual(kwargs, {
+ 'min_partitions': 1,
+ 'unknown_num_partitions': 200,
+ })
+ self.assertEqual(result, 1)
+
+ def test_insert_into_truncated_target_preserves_write_parallelism(self):
+ from pypaimon.ray import data_evolution_merge_into as m
+
+ target = self._create_table()
+ self._write(target, self._source(ids=(0,)))
+ table = self.catalog.get_table(target)
+ commit = table.new_batch_write_builder().new_commit()
+ commit.truncate_table()
+ commit.close()
+
+ real_write = m.distributed_write_collect_msgs
+ write_blocks = []
+
+ def capture(insert_ds, *args, **kwargs):
+ insert_ds = insert_ds.materialize()
+ write_blocks.append(insert_ds.num_blocks())
+ return real_write(insert_ds, *args, **kwargs)
+
+ with patch.object(
+ m, 'distributed_write_collect_msgs', side_effect=capture,
+ ):
+ metrics = merge_into(
+ target=target,
+ source=self._source(ids=range(1, 31)),
+ catalog_options=self.catalog_options,
+ on=['id'],
+ when_not_matched=[WhenNotMatched(insert='*')],
+ num_partitions=3,
+ )
+
+ self.assertEqual(metrics['num_inserted'], 30)
+ self.assertEqual(write_blocks, [3])
+ self.assertEqual(
+ self._read_sorted(target)['id'], list(range(1, 31))
+ )
+
def test_multi_source_match_raises_by_default(self):
# One target row matched by several source rows: the winning value is
# undefined (Spark DE's checkCardinality=false), so we refuse by
default.
@@ -4121,6 +4343,74 @@ class TargetProjectionTest(unittest.TestCase):
'id': 's.id',
'name': 's.name',
})
+ self.assertEqual(
+ target_renamed.join.call_args.kwargs['num_partitions'], 1
+ )
+
+ def test_matched_update_uses_target_left_context(self):
+ from pypaimon.ray.data_evolution_merge_join import (
+ build_matched_update_ds,
+ )
+ from pypaimon.ray.data_evolution_merge_transform import SourceColumnRef
+
+ source_ds = Mock()
+ source_ds.schema.return_value = pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ])
+ selected_ds = Mock()
+ source_renamed = Mock()
+ source_renamed.context = Mock(
+ target_max_block_size=512,
+ default_hash_shuffle_parallelism=7,
+ )
+ source_ds.select_columns.return_value = selected_ds
+ selected_ds.rename_columns.return_value = source_renamed
+
+ target_ds = Mock()
+ target_ds.schema.return_value = pa.schema([
+ ('_ROW_ID', pa.int64()),
+ ('id', pa.int32()),
+ ])
+ target_renamed = Mock()
+ target_renamed.context = Mock(
+ target_max_block_size=128,
+ default_hash_shuffle_parallelism=3,
+ )
+ joined = Mock()
+ target_ds.rename_columns.return_value = target_renamed
+ target_renamed.join.return_value = joined
+
+ with patch(
+ 'pypaimon.ray.ray_paimon.read_paimon',
+ return_value=target_ds,
+ ), patch(
+ 'ray.cluster_resources', return_value={'CPU': 320},
+ ), patch(
+ 'ray.data.context.DataContext.get_current',
+ side_effect=AssertionError('must use target Dataset context'),
+ ):
+ build_matched_update_ds(
+ target_identifier='default.target',
+ source_ds=source_ds,
+ target_on=['id'],
+ source_on=['id'],
+ clauses=[self._clause({'name': SourceColumnRef('name')})],
+ target_field_names=['id', 'name'],
+ target_pa_schema=pa.schema([
+ ('id', pa.int32()),
+ ('name', pa.string()),
+ ]),
+ update_cols=['name'],
+ catalog_options={'warehouse': '/tmp/warehouse'},
+ num_partitions=None,
+ estimated_size_bytes=512,
+ resolve_target_projection=lambda *args: ['id'],
+ )
+
+ join_kwargs = target_renamed.join.call_args.kwargs
+ # Target context: ceil(512 / 128) = 4. Source context would resolve 7.
+ self.assertEqual(join_kwargs['num_partitions'], 4)
def test_not_matched_insert_selects_needed_source_cols(self):
from pypaimon.ray.data_evolution_merge_join import (
@@ -4136,10 +4426,12 @@ class TargetProjectionTest(unittest.TestCase):
])
selected_ds = Mock()
source_renamed = Mock()
+ repartitioned = Mock()
result = object()
source_ds.select_columns.return_value = selected_ds
selected_ds.rename_columns.return_value = source_renamed
- source_renamed.map_batches.return_value = result
+ source_renamed.repartition.return_value = repartitioned
+ repartitioned.map_batches.return_value = result
out = build_not_matched_insert_ds(
target_identifier='default.target',
@@ -4163,6 +4455,7 @@ class TargetProjectionTest(unittest.TestCase):
'id': 's.id',
'name': 's.name',
})
+ source_renamed.repartition.assert_called_once_with(1)
class MergeConditionUnitTest(unittest.TestCase):
diff --git a/paimon-python/pypaimon/tests/ray_partitioning_test.py
b/paimon-python/pypaimon/tests/ray_partitioning_test.py
new file mode 100644
index 0000000000..0d131fd1f7
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_partitioning_test.py
@@ -0,0 +1,324 @@
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+import types
+import unittest
+from unittest import mock
+
+import pyarrow as pa
+import pytest
+
+pytest.importorskip("ray")
+
+from pypaimon.ray.data_evolution_merge_into import (
+ _estimate_merge_input_size_bytes,
+)
+from pypaimon.ray.partitioning import (
+ _estimate_dataset_num_rows,
+ _estimate_dataset_size_bytes,
+ _resolve_num_partitions,
+ _resolve_row_id_num_partitions,
+)
+
+
+class RayPartitioningTest(unittest.TestCase):
+
+ def test_explicit_num_partitions_is_unchanged(self):
+ with mock.patch(
+ "ray.cluster_resources",
+ side_effect=AssertionError("must not inspect the cluster"),
+ ):
+ self.assertEqual(_resolve_num_partitions(37, 1), 37)
+
+ def test_known_size_uses_target_block_size_and_cpu_cap(self):
+ from ray.data.context import DataContext
+
+ context = DataContext.get_current()
+ previous_target = context.target_max_block_size
+ context.target_max_block_size = 128 * 1024 * 1024
+ try:
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ):
+ self.assertEqual(_resolve_num_partitions(None, 0), 1)
+ self.assertEqual(
+ _resolve_num_partitions(None, 5 * 128 * 1024 * 1024),
+ 5,
+ )
+ self.assertEqual(
+ _resolve_num_partitions(None, 641 * 128 * 1024 * 1024),
+ 640,
+ )
+ # At the same average row width, a 10x larger input gets
+ # proportionally more partitions instead of both using 640.
+ self.assertEqual(
+ _resolve_num_partitions(None, 5_000_000 * 1024),
+ 39,
+ )
+ self.assertEqual(
+ _resolve_num_partitions(None, 50_000_000 * 1024),
+ 382,
+ )
+ self.assertEqual(
+ _resolve_num_partitions(None, 1, min_partitions=25),
+ 25,
+ )
+ self.assertEqual(
+ _resolve_num_partitions(None, 1, min_partitions=200),
+ 200,
+ )
+ finally:
+ context.target_max_block_size = previous_target
+
+ def test_known_size_uses_dataset_context(self):
+ import ray
+ from ray.data.context import DataContext
+
+ context = DataContext.get_current()
+ previous_target = context.target_max_block_size
+ try:
+ context.target_max_block_size = 128 * 1024 * 1024
+ data_context = ray.data.from_items([1]).context
+ context.target_max_block_size = 1024 * 1024 * 1024
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ):
+ self.assertEqual(
+ _resolve_num_partitions(
+ None,
+ 512 * 1024 * 1024,
+ data_context=data_context,
+ ),
+ 4,
+ )
+ finally:
+ context.target_max_block_size = previous_target
+
+ def test_unknown_size_keeps_cpu_default(self):
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ):
+ self.assertEqual(_resolve_num_partitions(None, None), 640)
+ self.assertEqual(
+ _resolve_num_partitions(
+ None,
+ None,
+ unknown_num_partitions=200,
+ ),
+ 200,
+ )
+
+ def test_dataset_estimate_does_not_call_size_bytes(self):
+ metadata = types.SimpleNamespace(size_bytes=1234)
+ dataset = types.SimpleNamespace(
+ _logical_plan=types.SimpleNamespace(
+ dag=types.SimpleNamespace(
+ infer_metadata=mock.Mock(return_value=metadata)
+ )
+ ),
+ size_bytes=mock.Mock(
+ side_effect=AssertionError("must not execute Dataset")
+ ),
+ )
+
+ self.assertEqual(_estimate_dataset_size_bytes(dataset), 1234)
+ dataset.size_bytes.assert_not_called()
+
+ def test_unknown_dataset_estimate_falls_back(self):
+ metadata = types.SimpleNamespace(size_bytes=None)
+ dataset = types.SimpleNamespace(
+ _logical_plan=types.SimpleNamespace(
+ dag=types.SimpleNamespace(
+ infer_metadata=mock.Mock(return_value=metadata)
+ )
+ )
+ )
+
+ self.assertIsNone(_estimate_dataset_size_bytes(dataset))
+
+ def test_ray_metadata_respects_transform_cardinality(self):
+ import inspect
+
+ import ray
+
+ dataset = ray.data.from_arrow(pa.table({"id": [1, 2]}))
+ self.assertEqual(_estimate_dataset_size_bytes(dataset), 16)
+ self.assertEqual(_estimate_dataset_num_rows(dataset), 2)
+
+ mapped = dataset.map_batches(lambda batch: batch)
+ self.assertIsNone(_estimate_dataset_size_bytes(mapped))
+ cardinality_parameter = inspect.signature(
+ ray.data.Dataset.map_batches
+ ).parameters.get("udf_modifying_row_count")
+ modifies_by_default = (
+ True
+ if cardinality_parameter is None
+ else cardinality_parameter.default
+ )
+ expected_rows = None if modifies_by_default else 2
+ self.assertEqual(_estimate_dataset_num_rows(mapped), expected_rows)
+
+ if "udf_modifying_row_count" in inspect.signature(
+ ray.data.Dataset.map_batches
+ ).parameters:
+ one_to_one = dataset.map_batches(
+ lambda batch: batch,
+ udf_modifying_row_count=False,
+ )
+ self.assertIsNone(_estimate_dataset_size_bytes(one_to_one))
+ self.assertEqual(_estimate_dataset_num_rows(one_to_one), 2)
+
+ widened = dataset.map_batches(
+ lambda batch: pa.table({
+ "payload": ["x" * 1_000_000] * batch.num_rows,
+ }),
+ batch_format="pyarrow",
+ udf_modifying_row_count=False,
+ )
+ self.assertIsNone(_estimate_dataset_size_bytes(widened))
+ self.assertEqual(_estimate_dataset_num_rows(widened), 2)
+ self.assertGreater(widened.materialize().size_bytes(), 1_000_000)
+
+ def test_legacy_map_batches_cardinality_is_unknown(self):
+ dependency = types.SimpleNamespace(
+ infer_metadata=mock.Mock(
+ return_value=types.SimpleNamespace(num_rows=2)
+ ),
+ can_modify_num_rows=None,
+ )
+
+ class MapBatches:
+ input_dependencies = (dependency,)
+
+ @staticmethod
+ def infer_metadata():
+ return types.SimpleNamespace(num_rows=None)
+
+ @property
+ def can_modify_num_rows(self):
+ return False
+
+ dataset = types.SimpleNamespace(
+ _logical_plan=types.SimpleNamespace(dag=MapBatches())
+ )
+ self.assertIsNone(_estimate_dataset_num_rows(dataset))
+
+ MapBatches.can_modify_num_rows = staticmethod(lambda: False)
+ self.assertIsNone(_estimate_dataset_num_rows(dataset))
+
+ def test_ray_legacy_map_batches_cardinality_is_unknown(self):
+ import inspect
+
+ import ray
+
+ dataset = ray.data.from_arrow(pa.table({"seed": [0]})).map_batches(
+ lambda _: pa.table({"id": list(range(500))}),
+ batch_format="pyarrow",
+ )
+ operator = dataset._logical_plan.dag
+ descriptor = inspect.getattr_static(
+ type(operator), "can_modify_num_rows", None
+ )
+ if not isinstance(descriptor, property):
+ self.skipTest("Ray does not use legacy MapBatches cardinality")
+
+ operator._input_dependencies = [
+ types.SimpleNamespace(
+ infer_metadata=mock.Mock(
+ return_value=types.SimpleNamespace(num_rows=1)
+ ),
+ can_modify_num_rows=None,
+ )
+ ]
+ self.assertIsNone(_estimate_dataset_num_rows(dataset))
+
+ def test_sparse_row_ids_keep_shuffle_parallelism(self):
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ):
+ self.assertEqual(
+ _resolve_row_id_num_partitions(None, 4096, 500, 500),
+ 200,
+ )
+ self.assertEqual(
+ _resolve_row_id_num_partitions(None, None, None, 500),
+ 200,
+ )
+ self.assertEqual(
+ _resolve_row_id_num_partitions(None, 32, 1, 500),
+ 1,
+ )
+ self.assertEqual(
+ _resolve_row_id_num_partitions(37, 1, 500, 500),
+ 37,
+ )
+
+ def test_row_id_sizing_uses_dataset_context(self):
+ data_context = types.SimpleNamespace(
+ target_max_block_size=128 * 1024 * 1024,
+ default_hash_shuffle_parallelism=25,
+ )
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ), mock.patch(
+ "ray.data.context.DataContext.get_current",
+ side_effect=AssertionError("must use the Dataset context"),
+ ):
+ self.assertEqual(
+ _resolve_row_id_num_partitions(
+ None,
+ 512 * 1024 * 1024,
+ None,
+ 500,
+ data_context=data_context,
+ ),
+ 25,
+ )
+
+ def test_merge_estimate_uses_only_reliable_source_size(self):
+ ctx = types.SimpleNamespace(
+ is_self_merge=False,
+ )
+
+ with mock.patch(
+ "pypaimon.ray.data_evolution_merge_into."
+ "_estimate_dataset_size_bytes",
+ return_value=100,
+ ):
+ result = _estimate_merge_input_size_bytes(
+ object(), ctx,
+ )
+
+ self.assertEqual(result, 100)
+
+ def test_merge_estimate_falls_back_when_source_size_is_unknown(self):
+ ctx = types.SimpleNamespace(is_self_merge=False)
+ with mock.patch(
+ "pypaimon.ray.data_evolution_merge_into."
+ "_estimate_dataset_size_bytes",
+ return_value=None,
+ ):
+ result = _estimate_merge_input_size_bytes(
+ object(), ctx,
+ )
+
+ self.assertIsNone(result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
index c49d359042..d14392634b 100644
--- a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
@@ -283,13 +283,23 @@ class RayReadByRowIdTest(unittest.TestCase):
captured = {}
def fake_read(rid_ds, table, projection, *, num_partitions,
- ray_remote_args=None, base_snapshot_id=None):
+ ray_remote_args=None, base_snapshot_id=None,
+ estimated_size_bytes=None, estimated_num_rows=None,
+ data_context=None):
captured["base_snapshot_id"] = base_snapshot_id
+ captured["num_partitions"] = num_partitions
+ captured["estimated_size_bytes"] = estimated_size_bytes
+ captured["estimated_num_rows"] = estimated_num_rows
+ captured["data_context"] = data_context
return ray.data.from_arrow(pa.table({"_ROW_ID": pa.array([],
pa.int64())}))
with mock.patch.object(m, "distributed_read_by_row_id", fake_read):
read_by_row_id(target, src, self.catalog_options,
projection=["age"])
self.assertEqual(captured["base_snapshot_id"], expected_sid)
+ self.assertIsNone(captured["num_partitions"])
+ self.assertGreater(captured["estimated_size_bytes"], 0)
+ self.assertEqual(captured["estimated_num_rows"], 1)
+ self.assertIsNotNone(captured["data_context"])
def test_accepts_pyarrow_and_pandas_source(self):
target = self._create()
diff --git a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
index e2bda92237..47dfe7f185 100644
--- a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import inspect
import os
import shutil
import tempfile
@@ -30,7 +31,7 @@ pypaimon = pytest.importorskip("pypaimon")
ray = pytest.importorskip("ray")
from pypaimon import CatalogFactory, Schema
-from pypaimon.ray import update_by_row_id
+from pypaimon.ray import read_paimon, update_by_row_id
class RayUpdateByRowIdTest(unittest.TestCase):
@@ -167,13 +168,221 @@ class RayUpdateByRowIdTest(unittest.TestCase):
captured = {}
def fake_apply(update_ds, table, cols, *, num_partitions,
- ray_remote_args=None, base_snapshot_id=None):
+ ray_remote_args=None, base_snapshot_id=None,
+ estimated_size_bytes=None, estimated_num_rows=None,
+ data_context=None):
captured["base_snapshot_id"] = base_snapshot_id
+ captured["num_partitions"] = num_partitions
+ captured["estimated_size_bytes"] = estimated_size_bytes
+ captured["estimated_num_rows"] = estimated_num_rows
+ captured["data_context"] = data_context
return [], 0, []
with mock.patch.object(m, "distributed_update_apply", fake_apply):
update_by_row_id(target, src, self.catalog_options,
update_cols=["age"])
self.assertEqual(captured["base_snapshot_id"], expected_sid)
+ self.assertIsNone(captured["num_partitions"])
+ self.assertGreater(captured["estimated_size_bytes"], 0)
+ self.assertEqual(captured["estimated_num_rows"], 1)
+ self.assertIsNotNone(captured["data_context"])
+
+ def test_transformed_paimon_source_adapts_partitions(self):
+ import pypaimon.ray.data_evolution_merge_join as merge_join
+
+ target = self._create()
+ for value in range(3):
+ self._write(target, pa.Table.from_pydict(
+ {"id": [value], "name": ["n{}".format(value)], "age": [0]},
+ schema=self.pa_schema,
+ ))
+
+ source = read_paimon(
+ target,
+ self.catalog_options,
+ projection=["_ROW_ID", "age"],
+ ).map_batches(
+ lambda batch: pa.table({
+ "_ROW_ID": batch.column("_ROW_ID"),
+ "age": pa.array([99] * batch.num_rows, type=pa.int32()),
+ }),
+ batch_format="pyarrow",
+ )
+ real_resolve = merge_join._resolve_row_id_num_partitions
+ resolved = []
+
+ def track_resolve(*args, **kwargs):
+ result = real_resolve(*args, **kwargs)
+ resolved.append((args, result))
+ return result
+
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ), mock.patch.object(
+ merge_join,
+ "_resolve_row_id_num_partitions",
+ side_effect=track_resolve,
+ ):
+ stats = update_by_row_id(
+ target,
+ source,
+ self.catalog_options,
+ update_cols=["age"],
+ )
+
+ self.assertEqual(stats, {"num_updated": 3})
+ self.assertEqual([result for _, result in resolved], [3])
+ cardinality_parameter = inspect.signature(
+ ray.data.Dataset.map_batches
+ ).parameters.get("udf_modifying_row_count")
+ expected_num_rows = (
+ 3
+ if cardinality_parameter is not None
+ and cardinality_parameter.default is False
+ else None
+ )
+ self.assertEqual(
+ resolved[0][0][1:], (None, expected_num_rows, 3)
+ )
+ self.assertEqual(self._read(target).column("age").to_pylist(), [99] *
3)
+
+ def test_expanding_transform_keeps_target_parallelism(self):
+ import pypaimon.ray.data_evolution_merge_join as merge_join
+
+ target = self._create()
+ for value in range(3):
+ self._write(target, pa.Table.from_pydict(
+ {"id": [value], "name": ["n{}".format(value)], "age": [0]},
+ schema=self.pa_schema,
+ ))
+
+ rows = read_paimon(
+ target,
+ self.catalog_options,
+ projection=["_ROW_ID"],
+ ).take_all()
+ row_ids = [row["_ROW_ID"] for row in rows]
+ map_kwargs = {"batch_format": "pyarrow"}
+ if "udf_modifying_row_count" in inspect.signature(
+ ray.data.Dataset.map_batches
+ ).parameters:
+ map_kwargs["udf_modifying_row_count"] = True
+ source = ray.data.from_arrow(pa.table({"seed": [0]})).map_batches(
+ lambda batch: pa.table({
+ "_ROW_ID": pa.array(row_ids, type=pa.int64()),
+ "age": pa.array([77] * len(row_ids), type=pa.int32()),
+ }),
+ **map_kwargs,
+ )
+ real_resolve = merge_join._resolve_row_id_num_partitions
+ resolved = []
+
+ def track_resolve(*args, **kwargs):
+ result = real_resolve(*args, **kwargs)
+ resolved.append((args, result))
+ return result
+
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ), mock.patch.object(
+ merge_join,
+ "_resolve_row_id_num_partitions",
+ side_effect=track_resolve,
+ ):
+ stats = update_by_row_id(
+ target,
+ source,
+ self.catalog_options,
+ update_cols=["age"],
+ )
+
+ self.assertEqual(stats, {"num_updated": 3})
+ self.assertEqual(resolved, [((None, None, None, 3), 3)])
+ self.assertEqual(self._read(target).column("age").to_pylist(), [77] *
3)
+
+ def test_filtered_paimon_source_uses_ray_shuffle_default(self):
+ import pypaimon.ray.data_evolution_merge_join as merge_join
+
+ target = "default.u_{}".format(uuid.uuid4().hex[:8])
+ schema = pa.schema([
+ ("content_key", pa.string()),
+ ("clip_id", pa.int32()),
+ ("age", pa.int32()),
+ ])
+ options = dict(self.de_options)
+ options["target-file-row-num"] = "1"
+ self.catalog.create_table(
+ target,
+ Schema.from_pyarrow_schema(schema, options=options),
+ False,
+ )
+ matched_rows = 39
+ total_rows = 201
+ self._write(target, pa.Table.from_pydict({
+ "content_key": ["wanted"] * matched_rows
+ + ["other"] * (total_rows - matched_rows),
+ "clip_id": [7] * matched_rows
+ + [8] * (total_rows - matched_rows),
+ "age": [0] * total_rows,
+ }, schema=schema))
+
+ table = self.catalog.get_table(target)
+ predicates = table.new_read_builder().new_predicate_builder()
+ predicate = predicates.and_predicates([
+ predicates.equal("content_key", "wanted"),
+ predicates.equal("clip_id", 7),
+ ])
+ source = read_paimon(
+ target,
+ self.catalog_options,
+ filter=predicate,
+ projection=["_ROW_ID", "content_key", "clip_id", "age"],
+ ).map_batches(
+ lambda batch: pa.table({
+ "_ROW_ID": batch.column("_ROW_ID"),
+ "age": pa.array([88] * batch.num_rows, type=pa.int32()),
+ }),
+ batch_format="pyarrow",
+ )
+ real_resolve = merge_join._resolve_row_id_num_partitions
+ real_groupby = ray.data.Dataset.groupby
+ resolved = []
+ group_partitions = []
+
+ def track_resolve(*args, **kwargs):
+ with mock.patch(
+ "ray.cluster_resources", return_value={"CPU": 320}
+ ):
+ result = real_resolve(*args, **kwargs)
+ resolved.append((args, result))
+ return result
+
+ def run_small_groupby(dataset, key, num_partitions=None):
+ # Assert the requested value but keep the test execution small.
+ group_partitions.append(num_partitions)
+ return real_groupby(dataset, key, num_partitions=4)
+
+ with mock.patch.object(
+ merge_join,
+ "_resolve_row_id_num_partitions",
+ side_effect=track_resolve,
+ ), mock.patch.object(
+ ray.data.Dataset,
+ "groupby",
+ new=run_small_groupby,
+ ):
+ stats = update_by_row_id(
+ target,
+ source,
+ self.catalog_options,
+ update_cols=["age"],
+ )
+
+ self.assertEqual(stats, {"num_updated": matched_rows})
+ self.assertEqual(resolved, [((None, None, None, total_rows), 200)])
+ self.assertEqual(group_partitions, [200])
+ ages = self._read(target).column("age").to_pylist()
+ self.assertEqual(ages.count(88), matched_rows)
+ self.assertEqual(ages.count(0), total_rows - matched_rows)
def test_new_commit_failure_preserves_pending_messages(self):
err = RuntimeError("new_commit failed")