blob: 9f03f482e33a2b947a8e8b024d76e680eb6b53a8 [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.
from collections import defaultdict
from typing import Any, List, Mapping, Optional, Sequence, Tuple
import pyarrow
import pyarrow as pa
from pypaimon.common.memory_size import MemorySize
from pypaimon.common.options.core_options import (
CoreOptions,
GlobalIndexSearchMode,
StartupMode,
)
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.globalindex import Range
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.read.scanner.file_scanner import FileScanner
from pypaimon.read.split import DataSplit
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.snapshot.time_travel_util import SCAN_KEYS, TimeTravelUtil
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
from pypaimon.write.writer.data_writer import DataWriter
from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
def _filter_by_whole_file_shard(splits: List[DataSplit], sub_task_id: int, total_tasks: int) -> List[DataSplit]:
list_ranges = []
for split in splits:
for file in split.files:
list_ranges.append(file.row_id_range())
sorted_ranges = Range.sort_and_merge_overlap(list_ranges, True, False)
start_range, end_range = _divide_ranges(sorted_ranges, sub_task_id, total_tasks)
if start_range is None or end_range is None:
return []
start_first_row_id = start_range.from_
end_first_row_id = end_range.to
def filter_data_file(f: DataFileMeta) -> bool:
return start_first_row_id <= f.first_row_id <= end_first_row_id
filtered_splits = []
for split in splits:
split = split.filter_file(filter_data_file)
if split is not None:
filtered_splits.append(split)
return filtered_splits
def _divide_ranges(
sorted_ranges: List[Range], sub_task_id: int, total_tasks: int
) -> Tuple[Optional[Range], Optional[Range]]:
if not sorted_ranges:
return None, None
num_ranges = len(sorted_ranges)
# If more tasks than ranges, some tasks get nothing
if sub_task_id >= num_ranges:
return None, None
# Calculate balanced distribution of ranges across tasks
base_ranges_per_task = num_ranges // total_tasks
remainder = num_ranges % total_tasks
# Each of the first 'remainder' tasks gets one extra range
if sub_task_id < remainder:
num_ranges_for_task = base_ranges_per_task + 1
start_idx = sub_task_id * (base_ranges_per_task + 1)
else:
num_ranges_for_task = base_ranges_per_task
start_idx = (remainder * (base_ranges_per_task + 1) + (sub_task_id - remainder) * base_ranges_per_task)
end_idx = start_idx + num_ranges_for_task - 1
return sorted_ranges[start_idx], sorted_ranges[end_idx]
class TableUpdate:
"""Common base for batch and stream table-update builders.
Holds the shared configuration (``update_cols``, ``projection``) and the
canonical ``commit_identifier``-aware implementations of the update /
upsert operations. The concrete subclasses
:class:`BatchTableUpdate` and :class:`StreamTableUpdate` expose
mode-specific public method signatures.
"""
def __init__(self, table, commit_user):
from pypaimon.table.file_store_table import FileStoreTable
self.table: FileStoreTable = table
self.commit_user = commit_user
self.update_cols = None
self.projection = None
def with_update_type(self, update_cols: List[str]):
for col in update_cols:
if col not in self.table.field_names:
raise ValueError(f"Column {col} is not in table schema.")
if len(update_cols) == len(self.table.field_names):
update_cols = None
self.update_cols = update_cols
return self
def with_read_projection(self, projection: List[str]):
self.projection = projection
def new_predicate_builder(self) -> PredicateBuilder:
return self.table.new_read_builder().new_predicate_builder()
def new_shard_updator(self, shard_num: int, total_shard_count: int):
"""Create a shard updater for scan+rewrite style updates.
Args:
shard_num: Index of this shard/subtask.
total_shard_count: Total number of shards/subtasks.
"""
return ShardTableUpdator(
self.table,
self.projection,
self.update_cols,
self.commit_user,
shard_num,
total_shard_count,
)
def _update_by_arrow_with_row_id(
self, table: pa.Table, commit_identifier: int
) -> List[CommitMessage]:
cols = self.update_cols if self.update_cols is not None else [
c for c in table.column_names if c != SpecialFields.ROW_ID.name
]
return TableUpdateByRowId(
self.table, self.commit_user, commit_identifier,
).update_columns(table, cols)
def _upsert_by_arrow_with_key(
self,
table: pa.Table,
upsert_keys: List[str],
commit_identifier: int,
) -> List[CommitMessage]:
"""Shared implementation for ``upsert_by_arrow_with_key``.
For each row in the input Arrow table:
* If one or more rows with the same composite ``upsert_keys`` value
already exist → update all of them in-place.
* Otherwise → append as a new row.
The public method lives on the concrete subclasses so each can
expose the signature appropriate to its mode (batch vs stream).
Args:
table: Input Arrow table containing rows to upsert.
upsert_keys: One or more column names forming the composite match key.
commit_identifier: Identifier to tag the produced commit messages with.
Returns:
List of :class:`CommitMessage` objects to be committed.
"""
return TableUpsertByKey(
self.table, self.commit_user, commit_identifier
).upsert(table, upsert_keys, self.update_cols)
def _upsert_by_key(
self,
rows,
upsert_keys: List[str],
commit_identifier: int,
) -> List[CommitMessage]:
return TableUpsertByKey(
self.table, self.commit_user, commit_identifier
).upsert_rows(rows, upsert_keys, self.update_cols)
def _merge_into(
self,
source: Any,
on,
when_matched: Sequence,
when_not_matched: Sequence,
commit_identifier: int,
) -> List[CommitMessage]:
from pypaimon.table.data_evolution_merge_into import merge_into
return merge_into(
self.table,
source,
on=on,
when_matched=when_matched,
when_not_matched=when_not_matched,
commit_user=self.commit_user,
commit_identifier=commit_identifier,
)
def _update_by_predicate(
self,
predicate: Optional[Predicate],
assignments: Mapping[str, Any],
commit_identifier: int,
) -> List[CommitMessage]:
"""Shared implementation for SQL-like ``UPDATE ... WHERE ...``.
``predicate`` identifies the target rows. ``assignments`` maps target
column names to literal values. The method reads matching ``_ROW_ID``
values, builds an Arrow update table, then delegates to the existing
row-id update path.
"""
self._validate_predicate_update(assignments)
scan_table = self._matched_update_scan_table()
read_builder = scan_table.new_read_builder()
if predicate is not None:
read_builder.with_filter(predicate)
read_builder.with_projection(
list(scan_table.field_names) + [SpecialFields.ROW_ID.name]
)
else:
read_builder.with_projection([SpecialFields.ROW_ID.name])
scan = read_builder.new_scan()
splits = scan.plan_for_write().splits()
matched = read_builder.new_read().to_arrow(splits)
if matched.num_rows == 0:
return []
update_table = self._build_predicate_update_table(
matched[SpecialFields.ROW_ID.name],
assignments,
matched.num_rows,
)
return TableUpdateByRowId(
self.table, self.commit_user, commit_identifier,
).update_columns(update_table, list(assignments.keys()))
def _matched_update_scan_table(self):
snapshot_manager = self.table.snapshot_manager()
snapshot = TimeTravelUtil.try_travel_to_snapshot(
self.table.options.options,
self.table.tag_manager(),
snapshot_manager,
)
if snapshot is None:
snapshot = snapshot_manager.get_latest_snapshot()
if snapshot is None:
return self.table
dynamic_options = {
CoreOptions.SCALAR_INDEX_SEARCH_MODE.key():
GlobalIndexSearchMode.FULL.value,
CoreOptions.SCAN_MODE.key(): StartupMode.DEFAULT.value,
CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot.id),
}
for scan_key in SCAN_KEYS:
if (
scan_key != CoreOptions.SCAN_SNAPSHOT_ID.key()
and self.table.options.options.contains_key(scan_key)
):
dynamic_options[scan_key] = None
return self.table.copy(dynamic_options)
def _validate_predicate_update(self, assignments: Mapping[str, Any]):
if not self.table.options.data_evolution_enabled():
raise ValueError(
"update_by_predicate requires "
"'data-evolution.enabled' = 'true'."
)
if not self.table.options.row_tracking_enabled():
raise ValueError(
"update_by_predicate requires "
"'row-tracking.enabled' = 'true'."
)
if not assignments:
raise ValueError("assignments must not be empty.")
partition_keys = set(self.table.partition_keys)
for col in assignments:
if col not in self.table.field_names:
raise ValueError(f"Column {col} is not in table schema.")
if col in partition_keys:
raise ValueError(
"update_by_predicate does not support updating "
f"partition column '{col}'."
)
def _build_predicate_update_table(
self,
row_ids,
assignments: Mapping[str, Any],
row_count: int,
) -> pa.Table:
table_schema = PyarrowFieldParser.from_paimon_schema(
self.table.table_schema.fields
)
arrays = [row_ids]
fields = [pa.field(SpecialFields.ROW_ID.name, pa.int64())]
for col, value in assignments.items():
target_field = table_schema.field(col)
arrays.append(
self._assignment_to_array(value, target_field.type, row_count)
)
fields.append(target_field)
return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
@staticmethod
def _assignment_to_array(
value: Any, data_type: pa.DataType, row_count: int):
if isinstance(value, pa.ChunkedArray):
array = value.combine_chunks()
elif isinstance(value, pa.Array):
array = value
else:
if isinstance(value, pa.Scalar):
value = value.as_py()
return pa.array([value] * row_count, type=data_type)
if len(array) != row_count:
raise ValueError(
"Assignment array length must match matched row count: "
f"{len(array)} != {row_count}."
)
if array.type != data_type:
array = array.cast(data_type)
return array
def _delete_by_predicate(
self,
predicate: Optional[Predicate],
commit_identifier: int,
) -> List[CommitMessage]:
partition_filter = self._partition_only_delete_filter(predicate)
if partition_filter is not None:
return self._delete_by_partition_filter(partition_filter)
row_ids = self._matched_delete_row_ids(predicate)
return TableDeleteByRowId(self.table).delete(row_ids)
def _delete_by_partition_filter(
self, partition_filter: Predicate) -> List[CommitMessage]:
snapshot = self.table.snapshot_manager().get_latest_snapshot()
if snapshot is None:
return []
messages = {}
manifest_list_manager = ManifestListManager(self.table)
data_entries = FileScanner(
self.table,
lambda: ([], None),
partition_predicate=partition_filter,
).read_manifest_entries(manifest_list_manager.read_all(snapshot))
for entry in data_entries:
message = self._partition_delete_message(
messages,
tuple(entry.partition.values),
entry.bucket,
snapshot.id,
)
message.deleted_files.append(entry.file)
for entry in self._partition_index_entries(snapshot, partition_filter):
message = self._partition_delete_message(
messages,
tuple(entry.partition.values),
entry.bucket,
snapshot.id,
)
message.index_deletes.append(IndexManifestEntry(
kind=1,
partition=entry.partition,
bucket=entry.bucket,
index_file=entry.index_file,
))
return [message for message in messages.values() if not message.is_empty()]
@staticmethod
def _partition_delete_message(messages, partition, bucket, snapshot_id):
key = (partition, bucket)
if key not in messages:
messages[key] = CommitMessage(
partition=partition,
bucket=bucket,
new_files=[],
check_from_snapshot=snapshot_id,
)
return messages[key]
def _partition_index_entries(self, snapshot, partition_filter: Predicate):
if snapshot.index_manifest is None:
return []
return [
entry for entry in IndexManifestFile(self.table).read(
snapshot.index_manifest)
if partition_filter.test(entry.partition)
]
def _delete_by_row_id(
self,
row_ids: Sequence[int],
commit_identifier: int,
) -> List[CommitMessage]:
return TableDeleteByRowId(self.table).delete(list(row_ids))
def _partition_only_delete_filter(
self, predicate: Optional[Predicate]) -> Optional[Predicate]:
if predicate is None or not self.table.partition_keys:
return None
predicate_fields = self._predicate_fields(predicate)
if not predicate_fields:
return None
partition_keys = set(self.table.partition_keys)
if not predicate_fields.issubset(partition_keys):
return None
partition_index = {
name: index for index, name in enumerate(self.table.partition_keys)
}
return self._rewrite_predicate_to_partition_indices(
predicate, partition_index
)
def _rewrite_predicate_to_partition_indices(
self,
predicate: Predicate,
partition_index: Mapping[str, int],
) -> Predicate:
if predicate.method in ('and', 'or'):
return predicate.new_literals([
self._rewrite_predicate_to_partition_indices(
child, partition_index,
)
for child in (predicate.literals or [])
])
if predicate.field not in partition_index:
raise ValueError(
"Partition delete predicate references non-partition "
f"field '{predicate.field}'."
)
return predicate.new_index(partition_index[predicate.field])
@staticmethod
def _predicate_fields(predicate: Predicate) -> set:
if predicate.field is not None:
return {predicate.field}
fields = set()
for child in predicate.literals or []:
fields.update(TableUpdate._predicate_fields(child))
return fields
def _matched_delete_row_ids(
self, predicate: Optional[Predicate]) -> List[int]:
scan_table = self._matched_update_scan_table()
read_builder = scan_table.new_read_builder()
if predicate is not None:
read_builder.with_filter(predicate)
read_builder.with_projection(
list(scan_table.field_names) + [SpecialFields.ROW_ID.name]
)
else:
read_builder.with_projection([SpecialFields.ROW_ID.name])
scan = read_builder.new_scan()
splits = scan.plan_for_write().splits()
matched = read_builder.new_read().to_arrow(splits)
if matched.num_rows == 0:
return []
return matched[SpecialFields.ROW_ID.name].to_pylist()
class BatchTableUpdate(TableUpdate):
"""Batch-mode table update; commit messages always use
:data:`BATCH_COMMIT_IDENTIFIER`."""
def update_by_arrow_with_row_id(self, table: pa.Table) -> List[CommitMessage]:
"""Apply column updates keyed by ``_ROW_ID`` to existing rows."""
return self._update_by_arrow_with_row_id(table, BATCH_COMMIT_IDENTIFIER)
def upsert_by_arrow_with_key(
self, table: pa.Table, upsert_keys: List[str]
) -> List[CommitMessage]:
"""Upsert rows into an append-only table by one or more key columns."""
return self._upsert_by_arrow_with_key(
table, upsert_keys, BATCH_COMMIT_IDENTIFIER
)
def upsert_by_key(
self, rows, upsert_keys: List[str]
) -> List[CommitMessage]:
"""Upsert rows into an append-only table by key columns."""
return self._upsert_by_key(
rows, upsert_keys, BATCH_COMMIT_IDENTIFIER
)
def update_by_predicate(
self,
predicate: Optional[Predicate],
assignments: Mapping[str, Any],
) -> List[CommitMessage]:
"""Update rows matching ``predicate`` with literal assignments."""
return self._update_by_predicate(
predicate, assignments, BATCH_COMMIT_IDENTIFIER
)
def delete_by_predicate(
self,
predicate: Optional[Predicate],
) -> List[CommitMessage]:
"""Delete rows matching ``predicate`` using deletion vectors."""
return self._delete_by_predicate(predicate, BATCH_COMMIT_IDENTIFIER)
def delete_by_row_id(self, row_ids: Sequence[int]) -> List[CommitMessage]:
"""Delete rows by ``_ROW_ID`` using deletion vectors."""
return self._delete_by_row_id(row_ids, BATCH_COMMIT_IDENTIFIER)
def merge_into(
self,
source: Any,
*,
on,
when_matched: Sequence = (),
when_not_matched: Sequence = (),
) -> List[CommitMessage]:
"""Prepare batch MERGE INTO commit messages."""
return self._merge_into(
source,
on,
when_matched,
when_not_matched,
BATCH_COMMIT_IDENTIFIER,
)
class StreamTableUpdate(TableUpdate):
"""Stream-mode table update; the same instance may drive many rounds,
each tagged with its own ``commit_identifier``."""
def update_by_arrow_with_row_id(
self, table: pa.Table, commit_identifier: int
) -> List[CommitMessage]:
"""Apply column updates keyed by ``_ROW_ID`` to existing rows,
tagging the produced commit messages with ``commit_identifier``."""
return self._update_by_arrow_with_row_id(table, commit_identifier)
def upsert_by_arrow_with_key(
self,
table: pa.Table,
upsert_keys: List[str],
commit_identifier: int,
) -> List[CommitMessage]:
"""Upsert rows into an append-only table by one or more key columns,
tagging the produced commit messages with ``commit_identifier``."""
return self._upsert_by_arrow_with_key(
table, upsert_keys, commit_identifier
)
def upsert_by_key(
self,
rows,
upsert_keys: List[str],
commit_identifier: int,
) -> List[CommitMessage]:
"""Upsert rows into an append-only table by key columns,
tagging the produced commit messages with ``commit_identifier``."""
return self._upsert_by_key(
rows, upsert_keys, commit_identifier
)
def update_by_predicate(
self,
predicate: Optional[Predicate],
assignments: Mapping[str, Any],
commit_identifier: int,
) -> List[CommitMessage]:
"""Update rows matching ``predicate`` with literal assignments,
tagging the produced commit messages with ``commit_identifier``."""
return self._update_by_predicate(
predicate, assignments, commit_identifier
)
def delete_by_predicate(
self,
predicate: Optional[Predicate],
commit_identifier: int,
) -> List[CommitMessage]:
"""Delete rows matching ``predicate`` using deletion vectors,
tagging the produced commit messages with ``commit_identifier``."""
return self._delete_by_predicate(predicate, commit_identifier)
def delete_by_row_id(
self,
row_ids: Sequence[int],
commit_identifier: int,
) -> List[CommitMessage]:
"""Delete rows by ``_ROW_ID`` using deletion vectors,
tagging the produced commit messages with ``commit_identifier``."""
return self._delete_by_row_id(row_ids, commit_identifier)
def merge_into(
self,
source: Any,
*,
on,
when_matched: Sequence = (),
when_not_matched: Sequence = (),
commit_identifier: int,
) -> List[CommitMessage]:
"""Prepare stream MERGE INTO commit messages."""
return self._merge_into(
source,
on,
when_matched,
when_not_matched,
commit_identifier,
)
class ShardTableUpdator:
def __init__(
self,
table,
projection: Optional[List[str]],
write_cols: List[str],
commit_user,
shard_num: int,
total_shard_count: int,
):
from pypaimon.table.file_store_table import FileStoreTable
self.table: FileStoreTable = table
self.projection = projection
self.write_cols = write_cols
self.commit_user = commit_user
self.total_shard_count = total_shard_count
self.shard_num = shard_num
self.write_pos = 0
self.writer: Optional[SingleWriter] = None
self.dict = defaultdict(list)
scanner = self.table.new_read_builder().new_scan()
plan = scanner.plan_for_write()
self.snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
splits = plan.splits()
splits = _filter_by_whole_file_shard(splits, shard_num, total_shard_count)
self.splits = splits
self.row_ranges: List[(Tuple, Range)] = []
for split in self.splits:
if not isinstance(split, DataSplit):
raise ValueError(f"Split {split} is not DataSplit.")
files = split.files
ranges = self.compute_from_files(files)
for row_range in ranges:
self.row_ranges.append((tuple(split.partition.values), row_range))
@staticmethod
def compute_from_files(files: List[DataFileMeta]) -> List[Range]:
ranges = [file.row_id_range() for file in files]
return Range.sort_and_merge_overlap(ranges, True, False)
def arrow_reader(self) -> pyarrow.ipc.RecordBatchReader:
read_builder = self.table.new_read_builder()
read_builder.with_projection(self.projection)
return read_builder.new_read().to_arrow_batch_reader(self.splits)
def prepare_commit(self) -> List[CommitMessage]:
commit_messages = []
for (partition, files) in self.dict.items():
commit_messages.append(CommitMessage(partition, 0, files, self.snapshot_id))
return commit_messages
def update_by_arrow_batch(self, data: pa.RecordBatch):
self._init_writer()
capacity = self.writer.capacity()
if capacity <= 0:
raise RuntimeError("Writer has no remaining capacity.")
# Split the batch across writers.
first, rest = (data, None) if capacity >= data.num_rows else (data.slice(0, capacity), data.slice(capacity))
self.writer.write(first)
if self.writer.capacity() == 0:
self.dict[self.writer.partition()].append(self.writer.end())
self.writer = None
if rest is not None:
if self.writer is not None:
raise RuntimeError("Should not get here, rest and current writer exist in the same time.")
self.update_by_arrow_batch(rest)
def _init_writer(self):
if self.writer is None:
if self.write_pos >= len(self.row_ranges):
raise RuntimeError(
"No more row ranges to write. "
"Ensure you write exactly the same number of rows as read from this shard."
)
item = self.row_ranges[self.write_pos]
self.write_pos += 1
partition = item[0]
row_range = item[1]
writer = AppendOnlyDataWriter(self.table, partition, 0, 0, self.table.options, self.write_cols)
# A shard maps to exactly one output file, so disable both size- and
# row-count based rolling; otherwise target-file-row-num would split
# the shard and SingleWriter.end() fails with "Should have one file."
writer.target_file_size = MemorySize.of_mebi_bytes(999999999).get_bytes()
writer.target_file_row_num = CoreOptions.TARGET_FILE_ROW_NUM.default_value()
self.writer = SingleWriter(writer, partition, row_range.from_, row_range.to - row_range.from_ + 1)
class SingleWriter:
def __init__(self, writer: DataWriter, partition, first_row_id: int, row_count: int):
self.writer: DataWriter = writer
self._partition = partition
self.first_row_id = first_row_id
self.row_count = row_count
self.written_records_count = 0
def capacity(self) -> int:
return self.row_count - self.written_records_count
def write(self, data: pa.RecordBatch):
if data.num_rows > self.capacity():
raise Exception("Data num size exceeds capacity.")
self.written_records_count += data.num_rows
self.writer.write(data)
return
def partition(self) -> Tuple:
return self._partition
def end(self) -> DataFileMeta:
if self.capacity() != 0:
raise Exception("There still capacity left in the writer.")
files = self.writer.prepare_commit()
if len(files) != 1:
raise Exception("Should have one file.")
file = files[0]
if file.row_count != self.row_count:
raise Exception("File row count mismatch.")
file = file.assign_first_row_id(self.first_row_id)
return file