blob: 99c400aa45862a8363ca44169db8185b173b4e2b [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.
"""Distributed row-id update on Ray for data-evolution tables.
Update columns of a data-evolution table straight from a Ray Dataset that already
carries ``_ROW_ID`` and the new values -- no full-target read and no big-table
shuffle join (unlike ``merge_into(on=["_ROW_ID"])``, which reads and joins the whole
target). Pairs with ``bucket_join``, which produces the row ids.
"""
from typing import Any, Dict, List, Optional
import pyarrow as pa
from pypaimon.ray.data_evolution_merge_into import (
_normalize_source,
_reraise_inner,
_require_ray_join,
)
from pypaimon.ray.data_evolution_merge_join import distributed_update_apply
from pypaimon.write.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"]
def _blob_col_names(table: "FileStoreTable") -> set:
return {f.name for f in table.table_schema.fields
if is_blob_file_field(f)}
def update_by_row_id(
target: str,
source: Any,
catalog_options: Dict[str, str],
*,
update_cols: List[str],
num_partitions: Optional[int] = None,
ray_remote_args: Optional[Dict[str, Any]] = None,
) -> Dict[str, int]:
"""Update ``update_cols`` of a data-evolution table by ``_ROW_ID``.
``source`` (a ``ray.data.Dataset`` / ``pyarrow.Table`` / ``pandas.DataFrame``)
must already carry the target ``_ROW_ID`` and the new values. Each row is
routed to the data file owning its row id and only those files are rewritten --
the target is never fully read and there is no join against it. Requires
``ray >= 2.50`` and a target with ``data-evolution.enabled`` + ``row-tracking.enabled``.
If concurrent compaction changes file boundaries, eligible staged updates are
rebased by Ray workers without reevaluating ``source``. The local Python
``data-evolution.row-id-conflict-rewrite.max-size`` limit does not apply.
Returns ``{"num_updated": <rows>}``.
"""
from pypaimon.catalog.catalog_factory import CatalogFactory
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.table.special_fields import SpecialFields
_require_ray_join()
if not update_cols:
raise ValueError("update_cols must be non-empty.")
update_cols = list(dict.fromkeys(update_cols)) # de-dup, keep order
table = CatalogFactory.create(catalog_options).get_table(target)
if not table.options.data_evolution_enabled():
raise ValueError(
f"update_by_row_id requires 'data-evolution.enabled'='true' on '{target}'.")
if not table.options.row_tracking_enabled():
raise ValueError(
f"update_by_row_id requires 'row-tracking.enabled'='true' on '{target}'.")
rid = SpecialFields.ROW_ID.name
blob_cols = _blob_col_names(table)
partition_keys = set(table.partition_keys or [])
for col in update_cols:
if col not in table.field_names:
raise ValueError(f"update column {col!r} is not in target '{target}'.")
if col in blob_cols:
# Update writes plain data files; blob deltas are a separate path.
raise ValueError(f"update_by_row_id cannot update blob column {col!r}.")
if col in partition_keys:
# In-place rewrite can't move a row across partitions.
raise ValueError(
f"update_by_row_id cannot update partition column {col!r}; "
"cross-partition row movement is not supported.")
if isinstance(source, str):
# A table's system _ROW_ID is its own, independent of the target's, so a
# table-name source can't address target rows. Require in-memory data that
# already carries the target row ids (e.g. produced by bucket_join).
raise ValueError(
"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:
raise ValueError(
f"source is missing columns {missing}; it must carry {rid} and {update_cols}.")
# Cast to the on-disk schema (int64 _ROW_ID + target column types) so the writer
# gets exactly the target types regardless of the source's arrow types.
target_pa = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
update_schema = build_update_schema(target_pa, update_cols, rid)
def _project_cast(batch: pa.Table) -> pa.Table:
return batch.select([rid] + update_cols).cast(update_schema)
update_ds = source_ds.map_batches(_project_cast, batch_format="pyarrow")
base = table.snapshot_manager().get_latest_snapshot()
if base is None or (base.total_record_count == 0
and not table.options.deletion_vectors_enabled()):
# Every source row id is foreign; don't silently no-op non-empty input.
if update_ds.limit(1).count() > 0:
raise ValueError(
f"target '{target}' has no rows; every _ROW_ID in the source is foreign.")
return {"num_updated": 0}
try:
msgs, num_updated, _ = distributed_update_apply(
update_ds, table, update_cols,
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)
raise # _reraise_inner always raises; keeps msgs/num_updated defined for linters
if msgs:
_commit_update_messages(
table, msgs, num_partitions=num_partitions, ray_remote_args=ray_remote_args)
return {"num_updated": num_updated}
def _commit_update_messages(
table, commit_messages, *, num_partitions=None, ray_remote_args=None) -> None:
from pypaimon.ray.row_id_conflict_rewriter import commit_updates_with_compaction_retry
try:
commit_updates_with_compaction_retry(
table, commit_messages, [],
num_partitions=num_partitions, ray_remote_args=ray_remote_args)
except Exception as e:
_reraise_inner(e)