blob: 9c6819be1118254f69e086f28a332abe8fa6dc6d [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 native row-ID updates for batch data-evolution tables."""
import json
from datetime import date, datetime, time
import pyarrow as pa
import pyarrow.compute as pc
from pypaimon.execution.native.table import _native_query_auth_supported
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.schema.arrow_schema import arrow_schemas_compatible
from pypaimon.schema.data_types import MapType, PyarrowFieldParser, RowType, VectorType, is_blob_file_field
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.file_store_table import FileStoreTable
from pypaimon.execution.native.write.native_commit import (
create_native_write_table, from_native_commit_messages,
)
from pypaimon.write.row_input import RowIdUpdateInputMixin, normalize_row_ids
from pypaimon.write.row_utils import _contains_blob_value, value_for_arrow
def _native_update_formats_supported(table):
return (table.options.file_format() == 'parquet'
and (not table.options.with_vector_format() or table.options.vector_file_format() == 'parquet'
or not any(isinstance(field.type, VectorType) for field in table.table_schema.fields)))
def _native_update_table(table, *, allow_changelog=False):
"""Resolve common native update prerequisites before preparing changes."""
if (type(table) is not FileStoreTable
or not table.options.native_write_enabled()
or not _native_update_formats_supported(table)
or (not allow_changelog and table.options.changelog_producer() != ChangelogProducer.NONE)
or not native_runtime_available()
or not _native_query_auth_supported(table)):
return None
native_table = create_native_write_table(table)
if native_table is not None:
from pypaimon.execution.native.table import _with_native_read_snapshot
native_table = _with_native_read_snapshot(table, native_table)
return native_table
def _native_row_id_table(table):
"""Row-level operations additionally require a data-evolution table."""
if (type(table) is not FileStoreTable
or not table.options.data_evolution_enabled()
or not table.options.row_tracking_enabled()
or table.is_primary_key_table):
return None
return _native_update_table(table)
def create_native_update(table, commit_user, columns):
"""Use the public core updater for direct and grouped row-ID updates."""
if not _native_update_columns_supported(table, columns):
return None
native_table = _native_row_id_table(table)
if native_table is None:
return None
writer = (native_table.new_batch_write_builder()
.with_commit_user(commit_user)
.new_update())
if columns is not None:
writer.with_update_type(columns)
return NativeBatchTableUpdate(table, writer)
def create_native_update_by_row_id(table, commit_user, commit_identifier, plan=None):
"""Create a core updater sharing one snapshot across incremental calls."""
# Columns are selected per call; one core updater pins the snapshot and
# tracks overlaps across Parquet and dedicated Blob updates.
if not _native_update_columns_supported(table, None):
return None
native_table = _native_row_id_table(
table._copy_with_snapshot(plan.table._read_snapshot) if plan is not None else table)
if native_table is None:
return None
if commit_identifier == BATCH_COMMIT_IDENTIFIER:
update = native_table.new_batch_write_builder().with_commit_user(commit_user).new_update()
else:
update = native_table.new_stream_write_builder().with_commit_user(commit_user).new_update()
if plan is not None:
from pypaimon.execution.native.write.row_id_plan import native_plan
update.with_plan(native_plan(plan.table, plan.plan))
writer = (update.new_update_by_row_id() if commit_identifier == BATCH_COMMIT_IDENTIFIER
else update.new_update_by_row_id(commit_identifier))
return NativeTableUpdateByRowId(
table, commit_user, commit_identifier, writer)
def create_native_shard_updator(table, commit_user, projection, columns, shard, shard_count, stream=False):
"""Core owns whole-file sharding, the captured reader and positional updates."""
if not _native_update_columns_supported(table, columns):
return None
native_table = _native_row_id_table(table)
if native_table is None:
return None
builder = (native_table.new_stream_write_builder().with_commit_user(commit_user) if stream else
native_table.new_batch_write_builder().with_commit_user(commit_user))
update = builder.new_update()
read_builder = table.new_read_builder()
if projection is not None:
read_builder.with_projection(projection)
if columns is not None:
update.with_update_type(columns)
read = read_builder.new_read()
writer = update.new_shard_updator(
shard, shard_count, read_type=json.dumps(RowType(True, read_builder.read_type()).to_dict()))
return NativeShardTableUpdator(table, read, writer)
class NativeShardTableUpdator:
"""Transport Arrow batches and commit messages for a core shard updater."""
def __init__(self, table, read, writer):
self._table = table
self._writer = writer
self._read = read
def arrow_reader(self):
from pypaimon.execution.native.read.table_read import NativeTableRead
from pypaimon.read.read_support import _ClosableArrowBatchReader
schema = self._read._output_arrow_schema()
batches = NativeTableRead(self._read).convert_batches(self._writer.arrow_reader(), schema)
return _ClosableArrowBatchReader(pa.ipc.RecordBatchReader.from_batches(schema, batches), batches)
def update_by_arrow_batch(self, data):
self._writer.update_by_arrow_batch(data)
def prepare_commit(self):
return from_native_commit_messages(self._table, self._writer.prepare_commit())
def close(self):
self._writer.close()
def _supported_upsert_key_type(data_type):
return any(check(data_type) for check in (
pa.types.is_boolean, pa.types.is_integer, pa.types.is_string,
pa.types.is_large_string, pa.types.is_binary, pa.types.is_large_binary,
pa.types.is_fixed_size_binary, pa.types.is_date, pa.types.is_decimal,
pa.types.is_time, pa.types.is_timestamp,
pa.types.is_floating,
))
def _dictionary_value_type(data_type):
while pa.types.is_dictionary(data_type):
data_type = data_type.value_type
return data_type
def _native_upsert_key_type_compatible(source, target):
"""Qualify layouts; exact values and conversion bounds belong to core."""
dictionary = pa.types.is_dictionary(source)
source = _dictionary_value_type(source)
if dictionary:
# Python's take kernel can select dictionary indices even when it
# cannot take the underlying view/run-end values directly. These
# layouts are therefore valid existing matched-upsert inputs.
while getattr(pa.types, 'is_run_end_encoded', lambda _: False)(source):
source = _dictionary_value_type(source.value_type)
if getattr(pa.types, 'is_string_view', lambda _: False)(source):
source = pa.string()
elif getattr(pa.types, 'is_binary_view', lambda _: False)(source):
source = pa.binary()
if pa.types.is_union(source):
# A Union can contain unused children of unrelated types. Qualify a
# possible match here; core validates only the active values exactly.
return any(_native_upsert_key_type_compatible(field.type, target) for field in source)
if pa.types.is_null(source):
return True
def numeric(data_type):
return (pa.types.is_integer(data_type) or pa.types.is_floating(data_type)
or pa.types.is_decimal(data_type))
if numeric(source) and numeric(target):
return True
for family in (pa.types.is_date, pa.types.is_time):
if family(source) and family(target):
return True
def binary(data_type):
return (pa.types.is_binary(data_type) or pa.types.is_large_binary(data_type)
or pa.types.is_fixed_size_binary(data_type))
if binary(source) and binary(target):
return True
if pa.types.is_timestamp(source) and pa.types.is_timestamp(target):
return (source.tz is None) == (target.tz is None)
return arrow_schemas_compatible(
pa.schema([pa.field('key', source)]), pa.schema([pa.field('key', target)]),
check_top_level_nullability=False, allow_binary_compatibility=True)
def _native_upsert_key_compatible(source, target):
if _native_upsert_key_type_compatible(source.type, target):
return True
def nulls(array):
if pa.types.is_dictionary(array.type):
# Index validity alone misses a selected NULL dictionary value.
# Select only its validity bitmap, without decoding key payloads.
return pc.fill_null(pc.take(nulls(array.dictionary), array.indices), True)
if pa.types.is_union(array.type):
# Unions have no validity bitmap. Only the active child's NULL
# counts; inactive children can contain unrelated non-NULL values.
# Read the parent buffers at its offset because PyArrow's union
# type_codes/offsets accessors can omit the offset of a slice.
buffers = array.buffers()
codes = pa.Array.from_buffers(pa.int8(), len(array), [None, buffers[1]], offset=array.offset)
offsets = (pa.Array.from_buffers(pa.int32(), len(array), [None, buffers[2]], offset=array.offset)
if array.type.mode == 'dense' else None)
result = pc.fill_null(pa.nulls(len(array), type=pa.bool_()), False)
for index, code in enumerate(array.type.type_codes):
active = pc.equal(codes, code)
child_nulls = nulls(array.field(index))
if offsets is not None:
child_nulls = pc.take(child_nulls, pc.if_else(active, offsets, None))
result = pc.if_else(active, child_nulls, result)
return result
return array.is_null()
try:
chunks = source.chunks if isinstance(source, pa.ChunkedArray) else [source]
for chunk in chunks:
if chunk.null_count != len(chunk) and not pc.all(nulls(chunk)).as_py():
return False
# Keep Python's existing input contract. In particular, bare view
# and run-end inputs have no take kernel on supported PyArrow.
pc.take(chunk, pa.array([], type=pa.int64()))
return True
except (pa.ArrowInvalid, pa.ArrowNotImplementedError, TypeError):
return False
def _native_update_columns_supported(table, columns):
"""Core writes Parquet columns and Java-compatible sparse Blob deltas."""
if type(table) is not FileStoreTable:
return False
names = set(table.field_names if columns is None else columns)
return not names.intersection(table.options.video_frame_fields())
def _temporal_row_key_supported(value, data_type):
"""Qualify row constructors without changing temporal matching semantics."""
if value is None:
return True
if pa.types.is_date(data_type):
# A datetime is distinct from a date even at midnight. Arrow's DATE
# constructor would discard this distinction before key matching.
return isinstance(value, date) and not isinstance(value, datetime)
if pa.types.is_timestamp(data_type):
if not isinstance(value, datetime):
return False
offset = value.utcoffset()
if (offset is not None) != (data_type.tz is not None):
return False
nanos = value.microsecond * 1000 + getattr(value, 'nanosecond', 0)
if offset is not None:
nanos -= offset.microseconds * 1000
elif pa.types.is_time(data_type):
if not isinstance(value, time):
return False
if value.utcoffset() is not None:
return False
nanos = value.microsecond * 1000
else:
return True
# Compare precision, not datetime objects: equivalent instants expressed
# with different timezones or DST folds must keep the existing core path.
resolution = {'s': 1000000000, 'ms': 1000000, 'us': 1000, 'ns': 1}[data_type.unit]
return nanos % resolution == 0
def _upsert_row_batches(rows, fields, schema, table=None, blob_rows=None, key_names=()):
"""Encode consecutive row shapes without padding absent fields with NULL.
Keep source order, including duplicates across shapes. Key matching,
last-write-wins and matched/append column validation belong to Rust core.
"""
encoded_keys = {}
for name in key_names:
key_type = schema.field(name).type
if any(not _temporal_row_key_supported(row[name], key_type) for row in rows):
return None
if not any(check(key_type) for check in (
pa.types.is_integer, pa.types.is_floating, pa.types.is_decimal,
pa.types.is_boolean, pa.types.is_string, pa.types.is_large_string,
pa.types.is_binary, pa.types.is_large_binary, pa.types.is_fixed_size_binary)):
continue
encoded = pa.array([row[name] for row in rows], type=key_type)
# Row constructors can change scalar identity before core sees a key.
# Qualify all keys before encoding any payload: a later unsafe row
# must not fall back after an earlier Blob input has been consumed.
if any(row[name] != encoded[index].as_py() for index, row in enumerate(rows)):
return None
encoded_keys[name] = encoded
batches = []
start = 0
while start < len(rows):
names = set(rows[start])
if not names <= set(schema.names):
raise ValueError('upsert row fields must be in the table schema')
end = start + 1
while end < len(rows) and set(rows[end]) == names:
end += 1
selected = [field for field in fields if field.name in names]
batch_schema = pa.schema([schema.field(field.name) for field in selected])
if blob_rows is not None:
batch = blob_rows.to_batch(table, rows[start:end], batch_schema.names)
else:
batch = pa.RecordBatch.from_pydict({
field.name: (encoded_keys[field.name].slice(start, end - start) if field.name in encoded_keys else
[value_for_arrow(row[field.name], field) for row in rows[start:end]])
for field in selected
}, schema=batch_schema)
batches.append(batch)
start = end
return batches
def create_native_upsert(table, commit_user, data, keys, columns):
"""Prepare one core upsert from Arrow columns or named row values."""
if table.options.video_frame_fields() or not _native_update_columns_supported(table, columns):
# An upsert can append unmatched rows; packed video writing is still
# provided by the Python writer.
return None
native_table = _native_row_id_table(table)
if native_table is None:
return None
fields = table.table_schema.fields
schema = PyarrowFieldParser.from_paimon_schema(fields)
blob_rows = None
if any(not _supported_upsert_key_type(schema.field(key).type) for key in set(keys + table.partition_keys)):
return None
if not isinstance(data, pa.Table):
if not columns or not data:
return None
if any(_contains_blob_value(value) for row in data for value in row.values()) or any(
is_blob_file_field(field) and isinstance(field.type, MapType) and field.name in row
for field in fields for row in data):
from pypaimon.execution.native.write.native_blob_rows import NativeBlobRows
blob_rows = NativeBlobRows()
data = _upsert_row_batches(
data, fields, schema, table, blob_rows, list(dict.fromkeys(keys + table.partition_keys)))
if data is None:
return None
else:
if (len(data.column_names) != len(set(data.column_names))
or not set(data.column_names) <= set(schema.names)):
return None
# Keys need a shared representation before matching. Core selects
# surviving payloads before applying row-ID update or append rules;
# validating all payload types here would reject valid matched updates.
key_names = list(dict.fromkeys(keys + table.partition_keys))
for name in key_names:
field = data.schema.field(name)
if pa.types.is_null(field.type):
# An untyped NULL has the same match identity as a typed NULL.
# Give core the declared key layout without casting payloads.
data = data.set_column(
data.schema.get_field_index(name), field.with_type(schema.field(name).type),
pa.nulls(data.num_rows, type=schema.field(name).type))
if not all(_native_upsert_key_compatible(data.column(name), schema.field(name).type)
for name in key_names):
return None
writer = (native_table.new_batch_write_builder()
.with_commit_user(commit_user)
.new_update()
.with_update_type(columns))
if blob_rows is not None:
writer.with_blob_uri_reader_factory(blob_rows)
return NativeTableUpsert(table, writer, keys, data, blob_rows)
def create_native_predicate_update(table, commit_user, predicate, columns=None):
"""Prepare a public core operation before any assignment can run."""
if not _native_update_columns_supported(table, columns):
return None
native_table = _native_row_id_table(table)
if native_table is None:
return None
from pypaimon.execution.native.read.builder import _predicate_to_native
native_predicate = None if predicate is None else _predicate_to_native(predicate)
if native_predicate is not None:
# Check predicate translation while fallback is still safe. Planning,
# reading and callback execution belong to the core operation below.
native_table.new_read_builder().with_filter(native_predicate)
writer = (native_table.new_batch_write_builder()
.with_commit_user(commit_user).new_update())
return NativePredicateTableUpdate(table, writer, native_predicate)
def create_native_predicate_delete(table, commit_user, predicate):
"""Delegate matching and partition metadata deletion to the core updater."""
from pypaimon.execution.native.read.builder import _predicate_to_native
if type(table) is not FileStoreTable:
return None
def canonical_paths(node):
if node.field_path and node.field_path != [node.field]:
return False
return (all(canonical_paths(child) for child in (node.literals or []))
if node.method in ('and', 'or') else True)
# Core selects whole-partition metadata deletes for append/PK tables too;
# only the row-level branch requires row tracking and deletion vectors.
from pypaimon.write.update_validation import predicate_fields
fields = set() if predicate is None else predicate_fields(predicate)
partition_delete = (table.is_primary_key_table and bool(fields)
and fields.issubset(table.partition_keys) and canonical_paths(predicate))
# Partition deletion removes complete file/index metadata. It does not
# produce row-level changes, even when the PK writer emits changelogs.
# Core still validates the translated predicate and selects this fast path.
native_table = _native_update_table(table, allow_changelog=partition_delete)
if native_table is None:
return None
native_predicate = None if predicate is None else _predicate_to_native(predicate)
if native_predicate is not None:
# Resolve all fields/operators before selecting the mutating operation.
native_table.new_read_builder().with_filter(native_predicate)
writer = (native_table.new_batch_write_builder()
.with_commit_user(commit_user).new_update())
return NativePredicateTableUpdate(table, writer, native_predicate)
def create_native_delete(table, commit_user, plan=None):
"""Select Rust's deletion-vector writer for supported batch deletes."""
if not table.options.deletion_vectors_enabled(False):
return None
native_table = _native_row_id_table(
table._copy_with_snapshot(plan.table._read_snapshot) if plan is not None else table)
if native_table is None:
return None
writer = (native_table.new_batch_write_builder()
.with_commit_user(commit_user)
.new_update())
if plan is not None:
from pypaimon.execution.native.write.row_id_plan import native_plan
writer.with_plan(native_plan(plan.table, plan.plan))
return NativeBatchTableUpdate(table, writer)
def create_native_row_id_delete(table, plan=None):
"""Adapt the direct deletion API to the existing core table updater."""
operation = create_native_delete(table, table.new_batch_write_builder().commit_user, plan)
return None if operation is None else NativeTableDeleteByRowId(operation)
class NativeTableDeleteByRowId:
def __init__(self, operation):
self._operation = operation
def delete(self, row_ids):
return self._operation.delete_by_row_id(row_ids)
def _raise_native_row_id_error(error):
detail = str(error)
if 'duplicate UPDATE operations' in detail:
raise ValueError('duplicate _ROW_ID: ' + detail) from error
if 'No file found for _ROW_ID' in detail:
raise ValueError(
detail + ' does not belong to any valid range') from error
raise error
class NativeBatchTableUpdate:
"""Wrap public core operations and decode their commit messages."""
def __init__(self, table, writer):
self.table = table
self.writer = writer
def update_by_arrow_with_row_id(self, data: pa.Table):
try:
messages = self.writer.update_by_arrow_with_row_id(data)
except ValueError as error:
_raise_native_row_id_error(error)
return from_native_commit_messages(self.table, messages)
def update_by_arrow_batches_with_row_id(self, tables):
try:
messages = self.writer.update_by_arrow_batches_with_row_id(tables)
except ValueError as error:
_raise_native_row_id_error(error)
return from_native_commit_messages(self.table, messages)
def delete_by_row_id(self, row_ids):
ids = normalize_row_ids(row_ids)
try:
messages = self.writer.delete_by_row_id(ids)
except ValueError as error:
_raise_native_row_id_error(error)
return from_native_commit_messages(self.table, messages)
class NativeTableUpdateByRowId(RowIdUpdateInputMixin):
"""Adapt Python row inputs around the core incremental Arrow updater."""
def __init__(self, table, commit_user, commit_identifier, writer):
self.table = table
self.commit_user = commit_user
self.commit_identifier = commit_identifier
self.writer = writer
@property
def commit_messages(self):
return from_native_commit_messages(self.table, self.writer.commit_messages)
def update_columns(self, data, column_names):
try:
messages = self.writer.update_columns(data, column_names)
except ValueError as error:
_raise_native_row_id_error(error)
return from_native_commit_messages(self.table, messages)
def _write_row_columns(self, data, column_names, blob_object_columns):
# Row input normalization happens in the public Python API; physical
# field layouts and Blob references are handled by the core updater.
if not blob_object_columns:
return self.update_columns(data, column_names)
from pypaimon.execution.native.write.native_blob_rows import NativeBlobRows
blobs = NativeBlobRows()
for name, values in blob_object_columns.items():
field = self.table.field_dict[name]
arrow_field = PyarrowFieldParser.from_paimon_field(field)
if isinstance(field.type, MapType):
# Arrow Map disallows NULL keys; Java's Blob map records allow
# one. Core accepts row entries as a list of nullable-key structs.
entries = pa.struct([pa.field('key', arrow_field.type.key_type),
pa.field('value', arrow_field.type.item_type)])
arrow_field = pa.field(name, pa.list_(entries), nullable=arrow_field.nullable)
data = data.append_column(arrow_field, pa.array(
[blobs.encode(value, field.type) for value in values], type=arrow_field.type))
self.writer.with_blob_uri_reader_factory(blobs)
try:
return self.update_columns(data, column_names)
finally:
self.writer.with_blob_uri_reader_factory(None)
class NativeTableUpsert:
"""Submit Arrow tables or row-shape batches to the core Rust upsert writer."""
def __init__(self, table, writer, keys, data, blob_rows=None):
self.table = table
self.writer = writer
self.keys = keys
self.data = data
self.blob_rows = blob_rows
def upsert(self):
try:
return from_native_commit_messages(
self.table,
self.writer.upsert_by_arrow_with_key(self.data, self.keys))
finally:
if self.blob_rows is not None:
self.blob_rows.clear()
class NativePredicateTableUpdate:
"""Convert operation inputs and commit messages around the core updater."""
def __init__(self, table, writer, predicate):
self.table = table
self.writer = writer
self.predicate = predicate
def update(self, assignments, read_columns):
return from_native_commit_messages(
self.table,
self.writer.update_by_predicate(
self.predicate, dict(assignments), list(read_columns or ())))
def delete(self):
return from_native_commit_messages(
self.table, self.writer.delete_by_predicate(self.predicate))