blob: 7141461e19bda5a3081e4b31883f2ba2fb22d456 [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.
"""Local column enrichment over Data Evolution tables."""
from dataclasses import dataclass
from typing import Optional, Tuple
import pyarrow as pa
import pyarrow.compute as pc
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.multimodal.table import (
_cast_merge_on_columns, _normalize_merge_on, _target_schema,
_to_arrow_table, _validate_merge_on, _validate_row_id_update_schema,
)
from pypaimon.schema.arrow_schema import normalize_arrow_write_nullability
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
from pypaimon.table.data_evolution_merge_into import WhenMatched, source_col
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
from pypaimon.write.row_input import normalize_row_ids
from pypaimon.write.update_validation import _authorize_update
@dataclass(frozen=True)
class ColumnUpdateResult:
"""Schema and data versions produced by a successful column operation.
``updated_rows`` is available for ``add_columns``, ``compute_columns``,
and ``update_columns_by_row_id``
and is ``None`` for ``merge_columns``, whose MERGE executor does not return
logical row counts.
"""
source_snapshot_id: Optional[int]
snapshot_id: Optional[int]
schema_id: int
updated_rows: Optional[int]
added_columns: Tuple[str, ...]
class ColumnUpdateError(RuntimeError):
"""A failed enrichment, with its last known publication state.
``schema_changed=None`` means the schema request had an unknown outcome.
``commit_outcome`` is ``not_started``, ``unknown``, or ``committed``. Never
retry blindly after an unknown outcome; inspect the table first. The
original failure is retained as ``__cause__``.
"""
def __init__(self, operation):
self.phase = operation.phase
self.schema_changed = operation.schema_changed
self.schema_id = operation.raw.table_schema.id
self.source_snapshot_id = operation.source_snapshot_id
self.commit_outcome = operation.commit_outcome
super().__init__(
"Column update failed during %s (schema_changed=%s, schema_id=%s, "
"source_snapshot_id=%s, commit_outcome=%s)."
% (self.phase, self.schema_changed, self.schema_id,
self.source_snapshot_id, self.commit_outcome))
def _validate_update(table):
selectors = SCAN_KEYS + CoreOptions.INCREMENTAL_SCAN_KEYS + [
CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
]
if (hasattr(table, "_read_snapshot")
or any(table.table_schema.options.get(key) is not None for key in selectors)
or table.table_schema.options.get("scan.mode", "default")
not in ("default", "latest", "latest-full")):
raise ValueError("Column updates require a current full-table view.")
_authorize_update(table)
class _ColumnUpdate:
def __init__(self, owner, raw):
self.owner = owner
self.raw = raw
self.snapshot = raw.snapshot_manager().get_latest_snapshot()
self.source_snapshot_id = None if self.snapshot is None else self.snapshot.id
self.added_columns = ()
self.schema_changed = False
self.commit_outcome = "not_started"
self.phase = "schema"
self.resources = []
def __enter__(self):
return self
def __exit__(self, exc_type, error, traceback):
for resource in reversed(self.resources):
try:
resource.close()
except Exception as close_error:
if error is None:
self.phase = "close"
error = close_error
if error is not None and isinstance(error, Exception):
raise ColumnUpdateError(self) from error
return False
def keep(self, resource):
self.resources.append(resource)
return resource
def add_schema(self, fields):
if not fields:
return
changes = [SchemaChange.add_column(
field.name, PyarrowFieldParser.to_paimon_type(field.type, True),
comment=(field.metadata or {}).get(b"description", b"").decode("utf-8") or None,
) for field in fields]
self.schema_changed = None
self.owner.catalog.alter_table(self.owner.identifier, changes)
self.schema_changed = True
# Only this operation's schema addition needs a new table definition.
# Ordinary calls keep the caller's existing handle, like add/update/merge.
raw = self.owner.catalog.get_table(self.owner.identifier).copy_without_time_travel(
getattr(self.raw, "_applied_dynamic_options", {}))
_authorize_update(raw)
self.raw = self.owner.raw_table = self.owner.table = raw
self.added_columns = tuple(field.name for field in fields)
def finish(self, updated_rows, commit=None, messages=()):
if messages:
self.phase = "commit"
self.commit_outcome = "unknown"
commit.commit(messages)
self.commit_outcome = "committed"
self.phase = "result"
snapshot = self.raw.snapshot_manager().get_latest_snapshot()
return ColumnUpdateResult(
self.source_snapshot_id, None if snapshot is None else snapshot.id,
self.raw.table_schema.id, updated_rows, self.added_columns)
def _validate_names(names, label):
if (not names or any(not isinstance(name, str) or not name for name in names)
or len(names) != len(set(names))):
raise ValueError("%s must contain nonempty, unique column names." % label)
reserved = set(names).intersection(SpecialFields.SYSTEM_FIELD_NAMES)
if reserved:
raise ValueError("%s cannot contain system columns: %s" % (label, sorted(reserved)))
def _canonical_field(field):
data_type = PyarrowFieldParser.to_paimon_type(field.type, field.nullable)
return field.with_type(PyarrowFieldParser.from_paimon_type(data_type))
def _validate_batch_size(batch_size):
if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0:
raise ValueError("batch_size must be a positive integer.")
def _validate_inputs(raw, inputs, fn):
if not callable(fn) or inputs is None:
raise ValueError("A callable fn and explicit inputs are required for column computation.")
inputs = [inputs] if isinstance(inputs, str) else list(inputs)
_validate_names(inputs, "inputs")
missing = set(inputs).difference(raw.field_names)
if missing:
raise ValueError("Input columns do not exist: %s" % sorted(missing))
return inputs
def _normalize_output(data, schema):
if not isinstance(data, (pa.Table, pa.RecordBatch)):
raise ValueError("Column function must return a pyarrow.Table or RecordBatch.")
if (len(data.schema.names) != len(schema.names)
or set(data.schema.names) != set(schema.names)):
raise ValueError("Output must contain exactly the declared columns: %s" % schema.names)
arrays = []
for field in schema:
array = data[field.name]
if not pa.types.is_null(array.type):
source_type = _canonical_field(pa.field(field.name, array.type)).type
# Same-type casts can reject child NULLs hidden by a NULL parent.
if array.type != source_type:
array = array.cast(source_type)
# Read schemas relax nested nullability; restore it before comparing types.
source = pa.Table.from_arrays([array], names=[field.name])
array = normalize_arrow_write_nullability(source, pa.schema([field]))[field.name]
if array.type != field.type:
raise ValueError("Incompatible type for column %s: %s != %s"
% (field.name, array.type, field.type))
if not field.nullable and array.null_count:
raise ValueError("Cannot write null to non-null column %s." % field.name)
arrays.append(array if array.type == field.type else array.cast(field.type))
return pa.Table.from_arrays(arrays, schema=schema)
def merge_columns(owner, data, *, on):
raw = owner.raw_table
_validate_update(raw)
data = _to_arrow_table(data)
_validate_names(data.column_names, "Source")
target_keys, source_keys = _normalize_merge_on(on, "merge_columns")
target_schema = _target_schema(raw)
_validate_merge_on(target_schema, data, target_keys, source_keys, "merge_columns")
data = _cast_merge_on_columns(data, target_schema, target_keys, source_keys)
output_names = [name for name in data.column_names if name not in source_keys]
_validate_names(output_names, "Output")
if set(output_names).intersection(raw.partition_keys + target_keys):
raise ValueError("merge_columns cannot update partition or match keys.")
if set(output_names).intersection(raw.options.video_frame_fields()):
raise ValueError("Use replace_video() to update video-frame columns.")
fields = [target_schema.field(name) if name in target_schema.names
else _canonical_field(data.schema.field(name)).with_nullable(True)
for name in output_names]
output = _normalize_output(data.select(output_names), pa.schema(fields))
source = data
for field, column in zip(output.schema, output.columns):
source = source.set_column(source.schema.get_field_index(field.name), field, column)
with _ColumnUpdate(owner, raw) as operation:
operation.add_schema([field for field in fields if field.name not in target_schema.names])
if source.num_rows == 0:
return operation.finish(None)
operation.phase = "write"
builder = operation.raw._copy_with_snapshot(operation.snapshot).new_batch_write_builder()
commit = operation.keep(builder.new_commit())
messages = builder.new_update().merge_into(
source, on=dict(zip(target_keys, source_keys)),
when_matched=[WhenMatched.update({name: source_col(name) for name in output_names})])
return operation.finish(None, commit=commit, messages=messages)
def update_columns_by_row_id(owner, data, *, columns):
if not isinstance(data, pa.Table):
raise ValueError("data must be a pyarrow.Table.")
raw = owner.raw_table
_validate_update(raw)
if not raw.options.data_evolution_enabled() or not raw.options.row_tracking_enabled():
raise ValueError("Row ID column updates require a Data Evolution table with row tracking.")
_validate_row_id_update_schema(owner)
row_id = SpecialFields.ROW_ID.name
if data.column_names.count(row_id) != 1:
raise ValueError("Input must contain exactly one _ROW_ID column.")
columns = [columns] if isinstance(columns, str) else list(columns)
_validate_names(columns, "Output")
missing = set(columns).difference(raw.field_names)
if missing:
raise ValueError("Output columns do not exist: %s" % sorted(missing))
if set(columns).intersection(raw.partition_keys):
raise ValueError("update_columns_by_row_id cannot update partition columns.")
if set(columns).intersection(raw.options.video_frame_fields()):
raise ValueError("Use replace_video() to update video-frame columns.")
for name in columns:
if data.column_names.count(name) != 1:
raise ValueError("Input must contain exactly one %s column." % name)
row_ids = normalize_row_ids(data[row_id])
if len(pc.unique(row_ids)) != data.num_rows:
raise ValueError("Input data contains duplicate _ROW_ID values.")
if data.num_rows and pc.min(row_ids).as_py() < 0:
raise ValueError("_ROW_ID values must be non-negative.")
target_schema = _target_schema(raw)
schema = pa.schema([target_schema.field(name) for name in columns])
source = _normalize_output(data.select(columns), schema).append_column(row_id, row_ids)
with _ColumnUpdate(owner, raw) as operation:
if source.num_rows == 0:
return operation.finish(0)
operation.phase = "validate"
table = raw._copy_with_snapshot(operation.snapshot)
read = table.new_read_builder().with_projection([row_id])
# Read only the requested row IDs, retaining deletion-vector filtering.
plan = read.new_scan().with_row_ranges(Range.to_ranges(row_ids.to_pylist())).plan_all()
visible = read.new_read().to_arrow(plan.splits())[row_id]
missing = pc.is_null(pc.index_in(row_ids, value_set=visible))
if pc.any(missing).as_py():
raise ValueError("Row IDs not found or deleted: %s" % row_ids.filter(missing).to_pylist())
operation.phase = "write"
builder = table.new_batch_write_builder()
commit = operation.keep(builder.new_commit())
messages = builder.new_update().with_update_type(columns).update_by_arrow_with_row_id(source)
return operation.finish(source.num_rows, commit=commit, messages=messages)
def add_columns(owner, schema, *, inputs, fn, batch_size):
if not isinstance(schema, pa.Schema):
raise ValueError("schema must be a pyarrow.Schema.")
_validate_names(schema.names, "Schema")
_validate_batch_size(batch_size)
if any(not field.nullable for field in schema):
raise ValueError("New columns must be nullable until backfill is complete.")
fields = [_canonical_field(field) for field in schema]
raw = owner.raw_table
_validate_update(raw)
existing = set(schema.names).intersection(raw.field_names)
if existing:
raise ValueError("Column already exists: %s" % sorted(existing))
if fn is None:
if inputs is not None:
raise ValueError("inputs requires fn.")
else:
inputs = _validate_inputs(raw, inputs, fn)
with _ColumnUpdate(owner, raw) as operation:
operation.add_schema(fields)
if fn is None:
return operation.finish(0)
return _compute_columns(operation, pa.schema(fields), inputs, fn, batch_size)
def compute_columns(owner, columns, *, inputs, fn, batch_size):
columns = [columns] if isinstance(columns, str) else list(columns)
_validate_names(columns, "Output")
_validate_batch_size(batch_size)
raw = owner.raw_table
_validate_update(raw)
missing = set(columns).difference(raw.field_names)
if missing:
raise ValueError("Output columns do not exist: %s" % sorted(missing))
if set(columns).intersection(raw.partition_keys):
raise ValueError("compute_columns cannot update partition columns.")
if set(columns).intersection(raw.options.video_frame_fields()):
raise ValueError("Use replace_video() to update video-frame columns.")
inputs = _validate_inputs(raw, inputs, fn)
target_schema = _target_schema(raw)
schema = pa.schema([target_schema.field(name) for name in columns])
with _ColumnUpdate(owner, raw) as operation:
return _compute_columns(operation, schema, inputs, fn, batch_size)
def _compute_columns(operation, schema, inputs, fn, batch_size):
operation.phase = "compute"
table = operation.raw.copy_without_time_travel({
"blob-as-descriptor": "false", "read.batch-size": str(batch_size),
})._copy_with_snapshot(operation.snapshot)
builder = table.new_batch_write_builder()
commit = operation.keep(builder.new_commit())
update = builder.new_update().with_update_type(schema.names)
update.with_read_projection(inputs)
shard = operation.keep(update.new_shard_updator(0, 1))
updated_rows = 0
for batch in shard.arrow_reader():
for start in range(0, batch.num_rows, batch_size):
operation.phase = "compute"
piece = batch.slice(start, batch_size)
output = _normalize_output(fn(piece), schema)
if output.num_rows != piece.num_rows:
raise ValueError("Column function must return one output row per input row, in input order.")
operation.phase = "write"
for output_batch in output.to_batches(max_chunksize=batch_size):
shard.update_by_arrow_batch(output_batch)
updated_rows += output.num_rows
operation.phase = "write"
messages = shard.prepare_commit()
return operation.finish(updated_rows, commit=commit, messages=messages)