blob: ad354a3bb5751b9feda7037ce9ec8fc51cff634f [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 bisect import bisect_right
from collections import deque
from itertools import islice
import logging
from typing import Any, Iterable, List, Mapping, Optional, Tuple
import pyarrow
import pyarrow as pa
from pypaimon.common.options.core_options import (
CoreOptions,
GlobalIndexSearchMode,
)
from pypaimon.common.predicate import Predicate
from pypaimon.globalindex import Range
from pypaimon.globalindex.indexed_split import IndexedSplit
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.execution.python.read.scanner.file_scanner import (FileScanner)
from pypaimon.execution.python.read.scanner.data_evolution_split_generator import (DataEvolutionSplitGenerator)
from pypaimon.read.split import DataSplit
from pypaimon.read.read_type import OutputProjection
from pypaimon.read.read_support import _ClosableArrowBatchReader, close_read_source
from pypaimon.read.table_read import TableRead
from pypaimon.schema.arrow_schema import normalize_arrow_write_nullability
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.update_validation import predicate_fields
from pypaimon.execution.python.write.row_id_file_index import RowIdFileIndex
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.utils.arrow_utils import zero_column_batch
from pypaimon.write.update_validation import _authorize_update
logger = logging.getLogger(__name__)
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:
if not (DataFileMeta.is_blob_file(file.file_name) or DataFileMeta.is_vector_file(file.file_name)):
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 PythonTableUpdate:
"""Execute an update using the public configuration."""
def __init__(self, context, commit_identifier=BATCH_COMMIT_IDENTIFIER, predicate=None,
*, has_callable=False, has_array=False):
self._context = context
self._commit_identifier = commit_identifier
self._predicate = predicate
self._has_callable = has_callable
self._has_array = has_array
def update_by_arrow_with_row_id(self, data):
context = self._context
columns = context.update_cols if context.update_cols is not None else [
name for name in data.column_names if name != SpecialFields.ROW_ID.name]
return self._new_row_id_updater(self._commit_identifier).update_columns(data, columns)
def update_by_arrow_batches_with_row_id(self, tables):
return self._update_by_arrow_batches_with_row_id(tables, self._commit_identifier)
def delete_by_row_id(self, row_ids):
return self._delete_rows_with_python(row_ids)
def update(self, assignments, read_columns):
return self._execute_predicate_update(
self._predicate, assignments, self._commit_identifier, read_columns,
self._has_callable, self._has_array)
def delete(self):
return self._execute_predicate_delete(self._predicate, self._commit_identifier)
def _new_row_id_updater(self, commit_identifier, _precomputed_files_info=None):
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
context = self._context
_authorize_update(context.table)
return TableUpdateByRowId._from_python(
context.table, context.commit_user, commit_identifier,
_precomputed_files_info=_precomputed_files_info)
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_all().splits()
matched = read_builder.new_read().to_arrow(splits)
if matched.num_rows == 0:
return []
return matched[SpecialFields.ROW_ID.name].to_pylist()
def _update_by_arrow_batches_with_row_id(
self, tables: Iterable[pa.Table], commit_identifier: int
) -> List[CommitMessage]:
context = self._context
updater = None
try:
for table in tables:
cols = context.update_cols if context.update_cols is not None else [
c for c in table.column_names
if c != SpecialFields.ROW_ID.name
]
if updater is None:
updater = self._new_row_id_updater(commit_identifier)
updater.update_columns(table, cols)
return [] if updater is None else updater.commit_messages
except Exception:
# Never delete files from CommitMessage on failure.
# A commit can succeed even when its response raises an exception.
raise
@staticmethod
def _predicate_update_file_groups(splits):
for split in splits:
data_split = (
split.data_split()
if isinstance(split, IndexedSplit) else split
)
deletion_files = data_split.data_deletion_files
deletion_by_name = (
{
file.file_name: deletion
for file, deletion in zip(
data_split.files, deletion_files
)
}
if deletion_files is not None else None
)
groups = DataEvolutionSplitGenerator._split_by_row_id(
data_split.files
)
for files in groups:
group = DataSplit(
files=files,
partition=data_split.partition,
bucket=data_split.bucket,
raw_convertible=len(files) == 1,
data_deletion_files=(
[deletion_by_name[file.file_name] for file in files]
if deletion_by_name is not None else None
),
snapshot_id=data_split.snapshot_id,
)
if isinstance(split, IndexedSplit):
group_ranges = Range.sort_and_merge_overlap(
[file.row_id_range() for file in files], True, True
)
row_ranges = Range.and_(
split.row_ranges(), group_ranges
)
if not row_ranges:
continue
group = IndexedSplit(group, row_ranges)
yield group
def _matched_update_scan_table(self):
context = self._context
snapshot = TimeTravelUtil.resolve_snapshot(context.table)
return context.table.copy_without_time_travel({
CoreOptions.SCALAR_INDEX_SEARCH_MODE.key():
GlobalIndexSearchMode.FULL.value,
})._copy_with_snapshot(snapshot)
def _build_predicate_update_table(
self,
assignments: Mapping[str, Any],
matched: pa.Table,
) -> pa.Table:
context = self._context
table_schema = PyarrowFieldParser.from_paimon_schema(
context.table.table_schema.fields
)
arrays = [matched[SpecialFields.ROW_ID.name]]
fields = [pa.field(SpecialFields.ROW_ID.name, pa.int64())]
for col, value in assignments.items():
if callable(value):
value = value(matched)
if not isinstance(value, (pa.Array, pa.ChunkedArray)):
raise ValueError(
f"Callable assignment for {col} must return a "
"pyarrow.Array or pyarrow.ChunkedArray."
)
target_field = table_schema.field(col)
arrays.append(
self._assignment_to_array(
value, target_field.type, matched.num_rows
)
)
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
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:
PythonTableUpdate._validate_assignment_struct_fields(
array.type, data_type)
if isinstance(array, pa.ChunkedArray):
array = pa.chunked_array(
[chunk.cast(data_type) for chunk in array.chunks],
type=data_type,
)
else:
array = array.cast(data_type)
return array
@staticmethod
def _validate_assignment_struct_fields(source, target):
if pa.types.is_struct(source) and pa.types.is_struct(target):
source_fields = {field.name: field for field in source}
target_names = {field.name for field in target}
shared_names = source_fields.keys() & target_names
if ([field.name for field in source if field.name in shared_names]
!= [field.name for field in target if field.name in shared_names]):
raise ValueError("Assignment struct fields must match in order.")
for target_field in target:
source_field = source_fields.get(target_field.name)
if source_field is not None:
PythonTableUpdate._validate_assignment_struct_fields(
source_field.type, target_field.type)
elif pa.types.is_map(source) and pa.types.is_map(target):
PythonTableUpdate._validate_assignment_struct_fields(
source.key_type, target.key_type)
PythonTableUpdate._validate_assignment_struct_fields(
source.item_type, target.item_type)
else:
list_types = (pa.types.is_list, pa.types.is_large_list,
pa.types.is_fixed_size_list)
if (any(check(source) for check in list_types)
and any(check(target) for check in list_types)):
PythonTableUpdate._validate_assignment_struct_fields(
source.value_type, target.value_type)
def _delete_by_partition_filter(
self, partition_filter: Predicate) -> List[CommitMessage]:
context = self._context
_authorize_update(context.table)
snapshot = TimeTravelUtil.resolve_snapshot(context.table)
if snapshot is None:
return []
messages = {}
manifest_list_manager = ManifestListManager(context.table)
data_entries = FileScanner(
context.table,
lambda: ([], None),
partition_predicate=partition_filter,
).with_all_buckets().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):
context = self._context
if snapshot.index_manifest is None:
return []
return [
entry for entry in IndexManifestFile(context.table).read(
snapshot.index_manifest)
if partition_filter.test(entry.partition)
]
def _partition_only_delete_filter(
self, predicate: Optional[Predicate]) -> Optional[Predicate]:
context = self._context
if predicate is None or not context.table.partition_keys:
return None
fields = predicate_fields(predicate)
if not fields:
return None
partition_keys = set(context.table.partition_keys)
if not fields.issubset(partition_keys):
return None
partition_index = {
name: index for index, name in enumerate(context.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])
def _execute_predicate_update(
self, predicate, assignments, commit_identifier, read_columns, has_callable, has_array):
scan_table = self._matched_update_scan_table()
read_builder = scan_table.new_read_builder()
if predicate is not None:
read_builder.with_filter(predicate)
if has_callable:
projection = list(dict.fromkeys(read_columns))
projection.append(SpecialFields.ROW_ID.name)
read_builder.with_projection(projection)
else:
read_builder.with_projection([SpecialFields.ROW_ID.name])
plan = read_builder.new_scan().plan_all()
splits = plan.splits()
snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
files_info = RowIdFileIndex.from_splits(
snapshot_id, splits
)
table_read = read_builder.new_read()
updater = self._new_row_id_updater(commit_identifier, files_info)
try:
if has_array:
matched = table_read.to_arrow(splits)
if matched.num_rows > 0:
update_table = self._build_predicate_update_table(
assignments,
matched,
)
updater.update_columns(
update_table, list(assignments.keys())
)
else:
for split in self._predicate_update_file_groups(splits):
matched = table_read.to_arrow([split], parallelism=1)
if matched.num_rows == 0:
continue
update_table = self._build_predicate_update_table(
assignments,
matched,
)
updater.update_columns(
update_table, list(assignments.keys())
)
except Exception:
# Never delete files from CommitMessage on failure.
# A commit can succeed even when its response raises an exception.
raise
return updater.commit_messages
def _execute_predicate_delete(self, predicate, commit_identifier):
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 self._delete_rows_with_python(row_ids)
def _delete_rows_with_python(self, row_ids):
context = self._context
return TableDeleteByRowId._from_python(context.table).delete(row_ids)
class _ShardBatchIterator:
"""Own a shard source even before its lazy batch generator starts."""
def __init__(self, batches, source):
self._batches = batches
self._source = source
def __iter__(self):
return self
def __next__(self):
return next(self._batches)
def close_source(self, primary_error=None):
source, self._source = self._source, None
if source is not None:
close_read_source(source, primary_error)
def close(self):
error = None
try:
self._batches.close()
except BaseException as failure:
error = failure
raise
finally:
self.close_source(error)
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
snapshot = TimeTravelUtil.resolve_snapshot(table)
self.table: FileStoreTable = table.copy_without_time_travel({
CoreOptions.SCALAR_INDEX_SEARCH_MODE.key(): GlobalIndexSearchMode.FULL.value,
})._copy_with_snapshot(snapshot)
self.projection = projection
self.write_cols = list(write_cols) if write_cols is not None else list(table.field_names)
self.commit_user = commit_user
self.total_shard_count = total_shard_count
self.shard_num = shard_num
scanner = self.table.new_read_builder().new_scan()
plan = scanner.plan_all()
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._files_info = RowIdFileIndex.from_splits(self.snapshot_id, splits)
self._write_schema = PyarrowFieldParser.from_paimon_schema(
[table.field_dict[name] for name in self.write_cols])
self.writer = None
self._pending_row_ids = deque()
self._group_batches = []
self._current_group = None
self._reader = None
self._reader_started = False
self._read_complete = not splits
self._read_failed = False
self._failed = False
self._closed = False
self._messages = None
@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:
self._ensure_active()
if self._reader_started:
raise ValueError('Shard reader can only be opened once')
read_builder = self.table.new_read_builder()
read_builder.with_projection(self.projection)
requested_read = read_builder.new_read()
output_schema = requested_read._output_arrow_schema()
read_type = list(read_builder.read_type())
if not any(field.name == SpecialFields.ROW_ID.name for field in read_type):
read_type.append(SpecialFields.ROW_ID)
projection = requested_read.output_projection
if projection is None:
row_id_column = next(i for i, field in enumerate(read_type) if field.name == SpecialFields.ROW_ID.name)
else:
name = '__paimon_shard_row_id'
while name in output_schema.names:
name += '_'
row_id_column = len(projection.columns)
projection = OutputProjection(
list(projection.columns) + [(name, [SpecialFields.ROW_ID.name])], projection.named)
read = TableRead(self.table, predicate=None, read_type=read_type, output_projection=projection)
source = read._to_managed_arrow_batch_reader(self.splits, parallelism=1)
self._reader_started = True
def batches():
error = None
try:
for batch in source:
self._ensure_active()
row_ids = batch.column(row_id_column)
if row_ids.null_count:
raise ValueError('Shard reader returned null _ROW_ID values')
self._pending_row_ids.extend(row_ids.to_pylist())
arrays = [batch.column(i) for i in range(len(output_schema))]
yield (pa.RecordBatch.from_arrays(arrays, schema=output_schema) if arrays else
zero_column_batch(batch.num_rows, output_schema.metadata))
self._read_complete = True
except BaseException as failure:
error = failure
if not isinstance(failure, GeneratorExit):
self._read_failed = True
raise
finally:
try:
iterator.close_source(error)
except BaseException:
self._read_failed = True
raise
iterator = _ShardBatchIterator(batches(), source)
reader = pa.ipc.RecordBatchReader.from_batches(output_schema, iterator)
self._reader = _ClosableArrowBatchReader(reader, iterator)
return self._reader
def prepare_commit(self) -> List[CommitMessage]:
if self._messages is not None:
return list(self._messages)
self._ensure_active()
if not self._read_complete or self._pending_row_ids:
raise ValueError('Read and update exactly the same number of rows from this shard before preparing')
self._flush_group()
self._messages = [] if self.writer is None else list(self.writer.commit_messages)
return list(self._messages)
def update_by_arrow_batch(self, data: pa.RecordBatch):
from pypaimon.execution.python.write.table_update_by_row_id import PythonTableUpdateByRowId
self._ensure_active()
if len(data.schema.names) != len(self.write_cols) or set(data.schema.names) != set(self.write_cols):
raise ValueError('Shard input must contain exactly the configured update columns')
arrays = [PythonTableUpdateByRowId._coerce_column(data.column(name), field.type)
for name, field in zip(self.write_cols, self._write_schema)]
normalized = normalize_arrow_write_nullability(pa.RecordBatch.from_arrays(arrays, schema=self._write_schema),
self._write_schema)
for field, column in zip(self._write_schema, normalized.columns):
if not field.nullable and column.null_count:
raise ValueError('Cannot write null to non-null column(%s)' % field.name)
if data.num_rows > len(self._pending_row_ids):
raise ValueError('Shard update contains more rows than have been read')
row_ids = list(islice(self._pending_row_ids, data.num_rows))
groups = [self._files_info.first_row_ids[bisect_right(self._files_info.first_row_ids, row_id) - 1]
for row_id in row_ids]
batch = pa.RecordBatch.from_arrays(
[pa.array(row_ids, type=pa.int64())] + list(normalized.columns),
names=[SpecialFields.ROW_ID.name] + self.write_cols)
try:
start = 0
while start < len(groups):
group = groups[start]
if self._current_group is not None and self._current_group != group:
self._flush_group()
self._current_group = group
end = start + 1
while end < len(groups) and groups[end] == group:
end += 1
self._group_batches.append(batch.slice(start, end - start))
start = end
except BaseException:
self._failed = True
raise
for _ in row_ids:
self._pending_row_ids.popleft()
def _init_writer(self):
if self.writer is None:
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
self.writer = TableUpdateByRowId._from_python(
self.table, self.commit_user, BATCH_COMMIT_IDENTIFIER,
_precomputed_files_info=self._files_info)
def _flush_group(self):
if self._group_batches:
self._init_writer()
# Preserve staged messages if writing fails; they may already have
# been published. A failed operation cannot later prepare success.
self._failed = True
self.writer.update_columns(pa.Table.from_batches(self._group_batches), self.write_cols)
self._failed = False
self._group_batches = []
self._current_group = None
def _ensure_active(self):
if self._closed or self._failed or self._read_failed or self._messages is not None:
raise ValueError('Shard updater is closed, failed or already prepared')
def close(self):
self._closed = True
self._pending_row_ids.clear()
self._group_batches = []
if self._reader is not None:
self._reader.close()