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 450a993e02 [python][ray] Fix HASH_FIXED primary-key writer ownership
(#8541)
450a993e02 is described below
commit 450a993e02b36c40b9e40b7e6ad2b692d12ebe7a
Author: QuakeWang <[email protected]>
AuthorDate: Mon Jul 13 10:36:19 2026 +0800
[python][ray] Fix HASH_FIXED primary-key writer ownership (#8541)
Ray `map_groups` only keeps a `(partition, bucket)` group intact while
the UDF runs. Its output may be split before `PaimonDatasink`, so sink
options that prevent operator fusion can create multiple independent
writers for one bucket with overlapping sequence numbers.
This change writes each HASH_FIXED primary-key group inside the
`map_groups` UDF, returns serialized commit messages, and commits them
on the driver. Writer concurrency and remote options are applied
directly to the group tasks.
---
docs/docs/pypaimon/ray-data.md | 34 +++---
paimon-python/pypaimon/ray/ray_paimon.py | 38 +++----
paimon-python/pypaimon/ray/shuffle.py | 20 ++--
.../pypaimon/tests/ray_repartition_test.py | 101 +++++++++++++++++
paimon-python/pypaimon/write/ray_datasink.py | 120 +++++++++++++++++++++
paimon-python/pypaimon/write/table_write.py | 17 ++-
6 files changed, 278 insertions(+), 52 deletions(-)
diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index a9bd84d38d..88c0a7143a 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -211,7 +211,9 @@ write_paimon(
**HASH_FIXED pre-clustering:**
HASH_FIXED rows are always assigned to the correct Paimon bucket by
-the writer. Pre-clustering is only a file-count optimization.
+the writer. For append-only tables, pre-clustering is only a file-count
+optimization. Primary-key tables additionally require one writer per
+`(partition_keys..., bucket)` group to generate ordered sequence numbers.
By default, `write_paimon` writes append-only HASH_FIXED tables
without pre-clustering. This avoids Ray `groupby().map_groups()`
@@ -220,9 +222,9 @@ node.
HASH_FIXED primary-key tables reject the default/off mode. Direct Ray
writes can send the same bucket to multiple writer tasks, and those
-writers can allocate overlapping sequence numbers. Use the explicit
-`map_groups` mode until a bounded pre-clustering strategy preserves
-per-bucket sequence ordering.
+writers can allocate overlapping sequence numbers. The explicit
+`map_groups` mode avoids this by running one writer for each complete
+`(partition_keys..., bucket)` group.
If every `(partition_keys..., bucket)` group fits in memory on a
single Ray node, you can opt in to the legacy small-file optimization:
@@ -237,11 +239,12 @@ write_paimon(
```
`hash_fixed_precluster="map_groups"` groups rows by
-`(partition_keys..., bucket)` before writing so each group lands in a
-single Ray task. This can reduce file count and keeps HASH_FIXED
-primary-key sequence generation per bucket in one writer task, but it
-inherits Ray's `map_groups()` memory bound. Large append-only buckets
-or hot append-only partitions should use the default mode or
+`(partition_keys..., bucket)`. For primary-key tables, the Paimon writer
+runs inside that `map_groups()` task and returns serialized commit
+messages for the driver to commit. Ray output block splitting therefore
+cannot create multiple writers for the same group. The mode inherits
+Ray's `map_groups()` memory bound. Large append-only buckets or hot
+append-only partitions should use the default mode or
`hash_fixed_precluster="off"`.
For non-HASH_FIXED append-only tables, the dataset is written as-is.
@@ -258,15 +261,18 @@ overlapping buckets or sequence numbers for those modes.
- `catalog_options`: kwargs forwarded to `CatalogFactory.create()`.
- `overwrite`: if `True`, overwrite existing data in the table.
- `concurrency`: optional max number of Ray write tasks to run concurrently.
+ For HASH_FIXED primary-key `map_groups` writes, this limits the group
+ writer tasks.
- `ray_remote_args`: optional kwargs passed to `ray.remote()` in write tasks
- (e.g. `{"num_cpus": 2}`).
+ (e.g. `{"num_cpus": 2}`). For HASH_FIXED primary-key `map_groups`
+ writes, these options apply to the group writer tasks.
- `hash_fixed_precluster`: HASH_FIXED pre-clustering mode. `"auto"` and
`"off"` write append-only HASH_FIXED tables directly and reject
HASH_FIXED primary-key tables. `"map_groups"` enables the legacy
- small-file optimization for HASH_FIXED primary-key tables and requires
- each `(partition_keys..., bucket)` group to fit in memory on one Ray
- node. This option does not enable Ray writes for HASH_DYNAMIC or
- CROSS_PARTITION primary-key tables.
+ small-file optimization for append-only tables and runs one writer per
+ HASH_FIXED primary-key group. Each `(partition_keys..., bucket)` group
+ must fit in memory on one Ray node. This option does not enable Ray
+ writes for HASH_DYNAMIC or CROSS_PARTITION primary-key tables.
### `TableWrite.write_ray()` (lower-level)
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py
b/paimon-python/pypaimon/ray/ray_paimon.py
index b375e3950f..3dcf40616d 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -292,12 +292,12 @@ def write_paimon(
"""Write a Ray Dataset to a Paimon table.
HASH_FIXED rows are assigned to the correct bucket by the Paimon
- writer. Optional pre-clustering is only a file-count optimization.
- The legacy ``map_groups`` pre-clustering mode materializes each
- ``(partition_keys..., bucket)`` group on one Ray node and should
- only be used when every group fits in memory. HASH_DYNAMIC and
- CROSS_PARTITION primary-key Ray writes are rejected because Ray
- write tasks create independent Paimon writers.
+ writer. For primary-key tables, ``map_groups`` writes each complete
+ ``(partition_keys..., bucket)`` group in one Ray task and returns
+ commit messages to the driver. It should only be used when every
+ group fits in memory. HASH_DYNAMIC and CROSS_PARTITION primary-key
+ Ray writes are rejected because Ray write tasks create independent
+ Paimon writers.
Args:
dataset: The Ray Dataset to write.
@@ -309,26 +309,22 @@ def write_paimon(
hash_fixed_precluster: HASH_FIXED pre-clustering mode. ``"auto"``
and ``"off"`` write append-only HASH_FIXED tables directly
and reject HASH_FIXED primary-key tables. ``"map_groups"``
- preserves the legacy small-file optimization and its single
- group memory bound for HASH_FIXED primary-key tables.
+ writes each HASH_FIXED primary-key group in one task and
+ preserves the legacy single-group memory bound.
"""
_require_ray_data()
from pypaimon.catalog.catalog_factory import CatalogFactory
- from pypaimon.ray.shuffle import maybe_apply_repartition
- from pypaimon.write.ray_datasink import PaimonDatasink
+ from pypaimon.write.ray_datasink import write_paimon_dataset
catalog = CatalogFactory.create(catalog_options)
table = catalog.get_table(table_identifier)
- dataset = maybe_apply_repartition(dataset, table, hash_fixed_precluster)
-
- datasink = PaimonDatasink(table, overwrite=overwrite)
-
- write_kwargs = {}
- if ray_remote_args is not None:
- write_kwargs["ray_remote_args"] = ray_remote_args
- if concurrency is not None:
- write_kwargs["concurrency"] = concurrency
-
- dataset.write_datasink(datasink, **write_kwargs)
+ write_paimon_dataset(
+ dataset,
+ table,
+ overwrite=overwrite,
+ concurrency=concurrency,
+ ray_remote_args=ray_remote_args,
+ hash_fixed_precluster=hash_fixed_precluster,
+ )
diff --git a/paimon-python/pypaimon/ray/shuffle.py
b/paimon-python/pypaimon/ray/shuffle.py
index 5079bf6215..eef29bdfc5 100644
--- a/paimon-python/pypaimon/ray/shuffle.py
+++ b/paimon-python/pypaimon/ray/shuffle.py
@@ -20,9 +20,10 @@
The legacy ``map_groups`` strategy groups rows by
``(partition_keys..., bucket)`` so every distinct group lands in a
-single Ray task. This can reduce file count, but Ray requires each
-``map_groups`` group to fit in memory on one node. Keep that strategy
-behind an explicit opt-in.
+single Ray task. Primary-key writes consume the complete group in that
+task; append-only writes use the regrouped rows as a file-count
+optimization. Ray requires each ``map_groups`` group to fit in memory
+on one node, so keep that strategy behind an explicit opt-in.
For append-only tables in any other bucket mode the dataset is returned
unchanged.
@@ -117,6 +118,15 @@ def maybe_apply_repartition(
)
return dataset
+ grouped, bucket_col = _group_by_partition_bucket(dataset, table)
+ regrouped = grouped.map_groups(_identity_batch, batch_format="pyarrow")
+ return regrouped.drop_columns([bucket_col])
+
+
+def _group_by_partition_bucket(
+ dataset: "ray.data.Dataset",
+ table: "Table",
+):
partition_keys = list(table.table_schema.partition_keys or [])
extractor = table.create_row_key_extractor()
col_names = set(f.name for f in table.table_schema.fields)
@@ -127,9 +137,7 @@ def maybe_apply_repartition(
bucket_udf, batch_format="pyarrow", zero_copy_batch=True,
)
group_keys: List[str] = partition_keys + [bucket_col]
- grouped = ds_with_bucket.groupby(group_keys)
- regrouped = grouped.map_groups(_identity_batch, batch_format="pyarrow")
- return regrouped.drop_columns([bucket_col])
+ return ds_with_bucket.groupby(group_keys), bucket_col
def _identity_batch(batch: pa.Table) -> pa.Table:
diff --git a/paimon-python/pypaimon/tests/ray_repartition_test.py
b/paimon-python/pypaimon/tests/ray_repartition_test.py
index 0d7dd568c0..8229d9467c 100644
--- a/paimon-python/pypaimon/tests/ray_repartition_test.py
+++ b/paimon-python/pypaimon/tests/ray_repartition_test.py
@@ -30,6 +30,9 @@ explicitly selected. These tests cover:
from the sink-visible schema.
* explicit ``map_groups`` mode can produce one file per
(partition, bucket) on the small test dataset.
+ * primary-key group writers remain single-writer when Ray splits the
+ UDF output or writer task options prevent downstream operator fusion.
+ * grouped primary-key writes preserve overwrite and empty-overwrite behavior.
* regression: a table whose schema already contains a column named
``__paimon_bucket__`` still works (collision-safe column name).
* non-HASH_FIXED append-only tables pass through unchanged.
@@ -364,6 +367,104 @@ class RayShuffleTest(unittest.TestCase):
# 4 buckets × 1 file each.
self.assertEqual(len(files), 4)
+ def test_primary_key_group_writer_does_not_depend_on_fusion(self):
+ """Output block splitting must not create independent bucket
writers."""
+ from ray.data import DataContext
+
+ from pypaimon.ray import write_paimon
+
+ pa_schema = pa.schema([
+ pa.field('id', pa.int32(), nullable=False),
+ ('value', pa.string()),
+ ])
+ table_name = 'test_pk_group_writer_no_fusion'
+ identifier = self._make_table(
+ table_name, pa_schema,
+ primary_keys=['id'], options={'bucket': '1'},
+ )
+ rows = pa.Table.from_pydict({
+ 'id': [1] * 800,
+ 'value': [f'{i:04d}-' + 'x' * 4091 for i in range(800)],
+ }, schema=pa_schema)
+
+ context = DataContext.get_current()
+ previous_target = context.target_max_block_size
+ context.target_max_block_size = 64 * 1024
+ try:
+ write_paimon(
+ ray.data.from_arrow(rows),
+ identifier,
+ self.catalog_options,
+ concurrency=4,
+ ray_remote_args={'num_cpus': 0.5},
+ hash_fixed_precluster='map_groups',
+ )
+ finally:
+ context.target_max_block_size = previous_target
+
+ table =
CatalogFactory.create(self.catalog_options).get_table(identifier)
+ data_files = [
+ data_file
+ for split in table.new_read_builder().new_scan().plan().splits()
+ for data_file in split.files
+ ]
+ self.assertEqual(len(data_files), 1)
+ self.assertEqual(
+ (data_files[0].min_sequence_number,
+ data_files[0].max_sequence_number),
+ (1, 801),
+ )
+
+ result = self._read_table(identifier)
+ self.assertEqual(len(result), 1)
+ self.assertTrue(result.iloc[0]['value'].startswith('0799-'))
+
+ def test_primary_key_group_writer_overwrite(self):
+ from pypaimon.ray import write_paimon
+
+ pa_schema = pa.schema([
+ pa.field('id', pa.int32(), nullable=False),
+ ('value', pa.string()),
+ ])
+ identifier = self._make_table(
+ 'test_pk_group_writer_overwrite', pa_schema,
+ primary_keys=['id'], options={'bucket': '1'},
+ )
+
+ initial = pa.Table.from_pydict(
+ {'id': [1, 2], 'value': ['a', 'b']}, schema=pa_schema,
+ )
+ write_paimon(
+ ray.data.from_arrow(initial), identifier, self.catalog_options,
+ hash_fixed_precluster='map_groups',
+ )
+
+ replacement = pa.Table.from_pydict(
+ {'id': [3], 'value': ['c']}, schema=pa_schema,
+ )
+ write_paimon(
+ ray.data.from_arrow(replacement),
+ identifier,
+ self.catalog_options,
+ overwrite=True,
+ concurrency=2,
+ ray_remote_args={'num_cpus': 0.5},
+ hash_fixed_precluster='map_groups',
+ )
+ self.assertEqual(set(self._read_table(identifier)['id']), {3})
+
+ empty = pa.Table.from_batches([], schema=pa_schema)
+ write_paimon(
+ ray.data.from_arrow(empty),
+ identifier,
+ self.catalog_options,
+ overwrite=True,
+ concurrency=2,
+ ray_remote_args={'num_cpus': 0.5},
+ hash_fixed_precluster='map_groups',
+ )
+ self.assertEqual(len(self._read_table(identifier)), 0)
+
def test_fixed_bucket_with_colliding_column_name(self):
"""A table that has a column named ``__paimon_bucket__`` must
still work — the helper picks a collision-free transient
diff --git a/paimon-python/pypaimon/write/ray_datasink.py
b/paimon-python/pypaimon/write/ray_datasink.py
index 18b7f024f0..6f7a336190 100644
--- a/paimon-python/pypaimon/write/ray_datasink.py
+++ b/paimon-python/pypaimon/write/ray_datasink.py
@@ -241,3 +241,123 @@ class PaimonDatasink(_DatasinkBase):
)
finally:
self._pending_commit_messages = []
+
+
+def write_paimon_dataset(
+ dataset,
+ table,
+ *,
+ overwrite: bool = False,
+ static_partition: Optional[Dict[str, Any]] = None,
+ concurrency: Optional[int] = None,
+ ray_remote_args: Optional[Dict[str, Any]] = None,
+ hash_fixed_precluster: str = "auto",
+) -> None:
+ """Write a Ray Dataset through the safe path for the table's bucket
mode."""
+ from pypaimon.ray.shuffle import (
+ HASH_FIXED_PRECLUSTER_MAP_GROUPS,
+ maybe_apply_repartition,
+ )
+ from pypaimon.table.bucket_mode import BucketMode
+
+ if (
+ hash_fixed_precluster == HASH_FIXED_PRECLUSTER_MAP_GROUPS
+ and table.bucket_mode() == BucketMode.HASH_FIXED
+ and getattr(table, "is_primary_key_table", False)
+ ):
+ _write_primary_key_groups(
+ dataset,
+ table,
+ overwrite=overwrite,
+ static_partition=static_partition,
+ concurrency=concurrency,
+ ray_remote_args=ray_remote_args,
+ )
+ return
+
+ dataset = maybe_apply_repartition(dataset, table, hash_fixed_precluster)
+ dataset.write_datasink(
+ PaimonDatasink(
+ table,
+ overwrite=overwrite,
+ static_partition=static_partition,
+ ),
+ concurrency=concurrency,
+ ray_remote_args=ray_remote_args,
+ )
+
+
+def _write_primary_key_groups(
+ dataset,
+ table,
+ *,
+ overwrite: bool,
+ static_partition: Optional[Dict[str, Any]],
+ concurrency: Optional[int],
+ ray_remote_args: Optional[Dict[str, Any]],
+) -> None:
+ import inspect
+ import pickle
+
+ from pypaimon.ray.shuffle import (
+ _coerce_large_string_types,
+ _group_by_partition_bucket,
+ )
+
+ grouped, bucket_col = _group_by_partition_bucket(dataset, table)
+ message_col = "__paimon_commit_messages__"
+ captured_table = table
+
+ # Keep the writer inside the group UDF. Ray may split the UDF output
+ # into multiple blocks, so only serialized commit messages leave it.
+ def _write_group(group: pa.Table) -> pa.Table:
+ if group.num_rows == 0:
+ return pa.table({message_col: pa.array([], type=pa.binary())})
+
+ rows = _coerce_large_string_types(
+ group.drop_columns([bucket_col])
+ )
+ worker_sink = PaimonDatasink(
+ captured_table,
+ overwrite=overwrite,
+ static_partition=static_partition,
+ )
+ commit_messages = worker_sink.write([rows], None)
+ return pa.table({
+ message_col: pa.array(
+ [pickle.dumps(commit_messages)], type=pa.binary()
+ )
+ })
+
+ map_kwargs = {"batch_format": "pyarrow"}
+ if concurrency is not None:
+ concurrency_param = inspect.signature(
+ grouped.map_groups
+ ).parameters.get("concurrency")
+ if (
+ concurrency_param is not None
+ and concurrency_param.kind != inspect.Parameter.VAR_KEYWORD
+ ):
+ map_kwargs["concurrency"] = concurrency
+ else:
+ from ray.data._internal.compute import TaskPoolStrategy
+ map_kwargs["compute"] = TaskPoolStrategy(size=concurrency)
+ if ray_remote_args:
+ map_kwargs.update(ray_remote_args)
+
+ messages = grouped.map_groups(_write_group, **map_kwargs)
+ coordinator = PaimonDatasink(
+ table,
+ overwrite=overwrite,
+ static_partition=static_partition,
+ )
+ coordinator.on_write_start()
+ try:
+ write_returns = []
+ for batch in messages.iter_batches(batch_format="pyarrow"):
+ for blob in batch.column(message_col).to_pylist():
+ write_returns.append(pickle.loads(blob))
+ coordinator.on_write_complete(write_returns)
+ except Exception as error:
+ coordinator.on_write_failed(error)
+ raise
diff --git a/paimon-python/pypaimon/write/table_write.py
b/paimon-python/pypaimon/write/table_write.py
index 4fb4b8043c..629383409f 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -120,31 +120,26 @@ class TableWrite:
hash_fixed_precluster: HASH_FIXED pre-clustering mode. ``"auto"``
and ``"off"`` write append-only HASH_FIXED tables directly
and reject HASH_FIXED primary-key tables. ``"map_groups"``
- preserves the legacy small-file optimization and its single
- group memory bound for HASH_FIXED primary-key tables.
+ writes each HASH_FIXED primary-key group in one task and
+ preserves the legacy single-group memory bound.
static_partition: Optional partition spec to overwrite. When set,
the Ray write runs in overwrite mode for this partition and
overrides any builder-level partition spec.
"""
- from pypaimon.ray.shuffle import maybe_apply_repartition
- from pypaimon.write.ray_datasink import PaimonDatasink
-
- dataset = maybe_apply_repartition(
- dataset, self.table, hash_fixed_precluster)
+ from pypaimon.write.ray_datasink import write_paimon_dataset
overwrite_partition = self.static_partition
if static_partition is not None:
overwrite_partition = static_partition
- datasink = PaimonDatasink(
+ write_paimon_dataset(
+ dataset,
self.table,
overwrite=overwrite,
static_partition=overwrite_partition,
- )
- dataset.write_datasink(
- datasink,
concurrency=concurrency,
ray_remote_args=ray_remote_args,
+ hash_fixed_precluster=hash_fixed_precluster,
)
def close(self):