blob: a3d5c2165dde9a339e686a3f1bd1de55612a7d13 [file]
################################################################################
# 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.
################################################################################
"""Optional pre-clustering and write guards for Ray writes.
The legacy ``map_groups`` strategy groups rows by
``(partition_keys..., bucket)`` so every distinct group lands in a
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.
"""
import uuid
from typing import TYPE_CHECKING, List
import pyarrow as pa
from pypaimon.table.bucket_mode import BucketMode
if TYPE_CHECKING:
import ray.data
from pypaimon.table.table import Table
# Default transient column name. A collision-safe variant is picked at
# runtime by ``_pick_bucket_col_name`` so user tables that happen to
# contain a column with this name still work correctly.
BUCKET_KEY_COL = "__paimon_bucket__"
WRITER_KEY_COL = "__paimon_writer_key__"
HASH_FIXED_PRECLUSTER_AUTO = "auto"
HASH_FIXED_PRECLUSTER_OFF = "off"
HASH_FIXED_PRECLUSTER_MAP_GROUPS = "map_groups"
HASH_FIXED_PRECLUSTER_MODES = frozenset([
HASH_FIXED_PRECLUSTER_AUTO,
HASH_FIXED_PRECLUSTER_OFF,
HASH_FIXED_PRECLUSTER_MAP_GROUPS,
])
def _pick_internal_col_name(existing_names, default_name) -> str:
if default_name not in existing_names:
return default_name
while True:
candidate = "{}_{}_".format(default_name, uuid.uuid4().hex[:8])
if candidate not in existing_names:
return candidate
def _pick_bucket_col_name(existing_names) -> str:
"""Return a collision-free transient bucket column name."""
return _pick_internal_col_name(existing_names, BUCKET_KEY_COL)
def maybe_apply_repartition(
dataset: "ray.data.Dataset",
table: "Table",
hash_fixed_precluster: str = HASH_FIXED_PRECLUSTER_AUTO,
) -> "ray.data.Dataset":
"""Optionally cluster rows for HASH_FIXED tables.
``auto`` currently behaves like ``off`` for append-only tables
because the old ``map_groups`` strategy materializes each
``(partition, bucket)`` group on one Ray node. For primary-key
tables, unsafe Ray write plans are rejected because multiple Ray
tasks create independent Paimon writers and can assign overlapping
sequence numbers.
"""
if hash_fixed_precluster not in HASH_FIXED_PRECLUSTER_MODES:
raise ValueError(
"hash_fixed_precluster must be one of {}, got {!r}".format(
sorted(HASH_FIXED_PRECLUSTER_MODES),
hash_fixed_precluster,
)
)
bucket_mode = table.bucket_mode()
is_primary_key_table = getattr(table, "is_primary_key_table", False)
if bucket_mode != BucketMode.HASH_FIXED:
if is_primary_key_table and bucket_mode in (
BucketMode.HASH_DYNAMIC,
BucketMode.CROSS_PARTITION,
):
raise ValueError(
"{} primary-key Ray writes are not supported. Multiple "
"Ray tasks create independent Paimon writers, which can "
"assign overlapping buckets or sequence numbers.".format(
bucket_mode.name
)
)
return dataset
if hash_fixed_precluster in (
HASH_FIXED_PRECLUSTER_AUTO,
HASH_FIXED_PRECLUSTER_OFF,
):
if is_primary_key_table:
raise ValueError(
"HASH_FIXED primary-key Ray writes require "
"hash_fixed_precluster='map_groups'. Direct writes can "
"create overlapping sequence numbers when multiple Ray "
"tasks write the same bucket."
)
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",
extractor=None,
):
partition_keys = list(table.table_schema.partition_keys or [])
if extractor is None:
extractor = table.create_row_key_extractor()
col_names = set(f.name for f in table.table_schema.fields)
bucket_col = _pick_bucket_col_name(col_names)
bucket_udf = _make_bucket_udf(extractor, bucket_col)
ds_with_bucket = dataset.map_batches(
bucket_udf, batch_format="pyarrow", zero_copy_batch=True,
)
group_keys: List[str] = partition_keys + [bucket_col]
return ds_with_bucket.groupby(group_keys), bucket_col
def _sort_by_partition_bucket_primary_key(
dataset: "ray.data.Dataset",
table: "Table",
extractor,
):
"""Sort rows so one primary key is owned by one Ray block."""
partition_keys = list(table.table_schema.partition_keys or [])
existing_names = set(f.name for f in table.table_schema.fields)
bucket_col = _pick_bucket_col_name(existing_names)
existing_names.add(bucket_col)
writer_key_col = _pick_internal_col_name(
existing_names, WRITER_KEY_COL
)
key_columns = list(table.trimmed_primary_keys)
key_fields = table.trimmed_primary_keys_fields
def _routing_keys(batch: pa.Table) -> pa.Table:
if batch.num_rows == 0:
buckets = []
writer_keys = []
else:
record_batch = batch.combine_chunks().to_batches()[0]
_, buckets = extractor.extract_partition_bucket_batch(
record_batch
)
columns = [batch.column(name) for name in key_columns]
writer_keys = [
extractor._binary_row_hash_code(
tuple(
column[row_index].as_py()
for column in columns
),
key_fields,
)
for row_index in range(batch.num_rows)
]
return batch.append_column(
bucket_col, pa.array(buckets, type=pa.int32())
).append_column(
writer_key_col, pa.array(writer_keys, type=pa.uint32())
)
with_keys = dataset.map_batches(
_routing_keys, batch_format="pyarrow", zero_copy_batch=True,
)
# Ray keeps equal sort keys in one block. Hash collisions only
# co-locate additional primary keys.
sort_keys: List[str] = partition_keys + [bucket_col, writer_key_col]
return with_keys.sort(sort_keys), [bucket_col, writer_key_col]
def _identity_batch(batch: pa.Table) -> pa.Table:
# Some Ray versions promote ``string`` to ``large_string`` (and
# ``binary`` to ``large_binary``) while materialising blocks for
# ``groupby().map_groups``. Paimon's writer compares schemas with a
# strict ``!=`` and rejects the large variants, so coerce them back
# to the regular types here. Other Arrow types pass through.
return _coerce_large_string_types(batch)
def _coerce_large_string_types(batch: pa.Table) -> pa.Table:
needs_cast = False
fields = []
for field in batch.schema:
if pa.types.is_large_string(field.type):
fields.append(field.with_type(pa.string()))
needs_cast = True
elif pa.types.is_large_binary(field.type):
fields.append(field.with_type(pa.binary()))
needs_cast = True
else:
fields.append(field)
return batch.cast(pa.schema(fields)) if needs_cast else batch
def _make_bucket_udf(extractor, bucket_col):
"""Build a map_batches UDF that appends a transient bucket column.
The bucket value comes from ``extract_partition_bucket_batch`` so it
matches the writer's bucket assignment for the same row exactly.
"""
def _udf(batch: pa.Table) -> pa.Table:
if batch.num_rows == 0:
return batch.append_column(
bucket_col, pa.array([], type=pa.int32())
)
record_batch = batch.combine_chunks().to_batches()[0]
_, buckets = extractor.extract_partition_bucket_batch(record_batch)
return batch.append_column(
bucket_col, pa.array(buckets, type=pa.int32())
)
return _udf