blob: 5817abe62256c7405d6b2fe6146e6c47b85bacb7 [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 typing import List, Union
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.execution.python.read.reader.deduplicate_merge_function import (DeduplicateMergeFunction)
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.table.row.key_value import KeyValue
from pypaimon.table.special_fields import SpecialFields
from pypaimon.execution.python.write.writer.data_writer import DataWriter
from pypaimon.execution.python.write.writer.write_buffer import WriteBuffer
class KeyValueDataWriter(DataWriter):
"""Data writer for primary key tables with system fields and sorting.
Accumulates incoming batches in the write buffer without sorting or
folding on the write path. Sort and ``MergeFunction``-based fold
are deferred to flush time (``_flush_all``), where the result is
roll-written into one or more data files. This enforces the LSM
"PK unique within a file" invariant the read-side
``raw_convertible`` fast path relies on, while keeping per-write
cost bounded.
"""
def __init__(self, table, partition, bucket, max_seq_number,
options=None, write_cols=None, merge_function=None,
changelog_producer=ChangelogProducer.NONE):
super().__init__(table, partition, bucket, max_seq_number,
options, write_cols, changelog_producer)
# Defaults to deduplicate so direct callers (tests / future code
# paths that don't go through FileStoreWrite) don't accidentally
# skip the merge step entirely.
self._merge_function = merge_function or DeduplicateMergeFunction()
# Java's INPUT producer records every accepted input before the merge
# engine folds equal keys. Changelog files have their own rolling and
# metadata, independent of the data files produced by that fold.
self._input_changelog_buffer = (
WriteBuffer(self._merge_data)
if changelog_producer == ChangelogProducer.INPUT
and self._writes_changelog_before_merge() else None)
def _writes_changelog_before_merge(self):
return True
def _process_data(self, data: pa.RecordBatch) -> pa.Table:
# No sort here: sorting once at flush is strictly cheaper than
# per-batch sort + a final global sort. The buffer ends up as a
# concat of unsorted batches; ``_flush_all`` sorts it exactly
# once before folding.
enhanced_data = self._add_system_fields(data)
result = pa.Table.from_batches([enhanced_data])
if self._input_changelog_buffer is not None:
self._input_changelog_buffer.append(result)
return result
def _merge_data(self, existing_data: pa.Table, new_data: pa.Table) -> pa.Table:
# Plain concat. Sort + fold both run inside ``_flush_all`` so
# N writes incur 1 sort instead of N sorts.
return pa.concat_tables([existing_data, new_data])
def prepare_commit(self) -> List[DataFileMeta]:
if self._buffer.num_rows > 0 or self._has_pending_input_changelog():
self._flush_all()
# ``_flush_all`` empties the buffer, so super's prepare_commit just
# returns ``committed_files``.
return super().prepare_commit()
def _check_and_roll_if_needed(self):
# Buffer overflowed target_file_size: sort + fold + roll-write the whole
# buffer as multiple files in one pass. Unlike the base class's slice
# loop, a successful flush leaves no remainder -- it empties the buffer
# outright. There is no row-count trigger here, and none is needed:
# ``FileStoreWrite`` rejects target-file-row-num on primary key tables.
if self._buffer.num_rows > 0 and self._buffer.nbytes > self.target_file_size:
self._flush_all()
def close(self):
# Override the base ``close`` because its straight
# ``_write_data_to_file`` of the whole buffer would land an unsorted,
# un-folded table on disk -- violating the file-internal
# PK-unique invariant. Route the final flush through
# ``_flush_all`` so the contract holds even on the
# close-without-prepare_commit path.
try:
if self._buffer.num_rows > 0 or self._has_pending_input_changelog():
self._flush_all()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(
"Exception occurs when closing writer. Cleaning up.",
exc_info=e)
self.abort()
raise e
finally:
self._buffer.reset()
def _flush_all(self) -> None:
"""Sort + fold the entire buffer, then roll-write as files.
On return, the buffer is empty and every flushed chunk has been
recorded in ``committed_files``. If a file write fails, the buffer is
left holding exactly the rows no file has taken yet, so a retried
flush neither loses nor duplicates them.
"""
self._flush_input_changelog()
pending = self._buffer.materialize()
if pending is None or pending.num_rows == 0:
self._buffer.reset()
return
sorted_data = self._sort_by_primary_key(pending)
folded = self._merge_pending_by_pk(sorted_data)
if folded.num_rows == 0:
self._buffer.reset()
return
# Park the folded rows in the buffer for ``_roll_write`` to drain. Both
# the sort and the fold are idempotent on their own output -- PKs are
# unique once folded -- so a retry over what is left is still correct.
self._buffer.reset(folded)
self._roll_write(folded)
def _has_pending_input_changelog(self):
return self._input_changelog_buffer is not None and self._input_changelog_buffer.num_rows > 0
def _flush_input_changelog(self):
if not self._has_pending_input_changelog():
return
data = self._sort_by_primary_key(self._input_changelog_buffer.materialize())
while data.num_rows:
split_row = data.num_rows
if data.nbytes > self.target_file_size:
split_row = max(1, self._find_optimal_split_point(data, self.target_file_size))
logical_data = data.slice(0, split_row)
statistics = self._data_file_statistics(logical_data)
physical_data = (self._apply_variant_shredding(logical_data)
if self._variant_shredding else logical_data)
meta = self._write_changelog_file(
physical_data, is_external=self.external_path_provider is not None, **statistics)
self.committed_changelog_files.append(meta)
# Advance only after file and metadata are complete. A data-file
# failure later must neither lose inputs nor write their logs twice.
data = data.slice(split_row)
self._input_changelog_buffer.reset(data if data.num_rows else None)
def abort(self):
super().abort()
if self._input_changelog_buffer is not None:
self._input_changelog_buffer.reset()
def _roll_write(self, data: pa.Table) -> None:
"""Write ``data`` as one or more files, each <= target_file_size.
``data`` is required to be PK-unique (the fold guarantees
that), so any slice of it is also PK-unique -- splitting for
size does not violate the LSM file-internal invariant.
Reuses ``_find_optimal_split_point`` / ``_write_data_to_file``
from the base class.
The buffer is narrowed to the rows still unwritten after each file, so
a failure part way through leaves the remainder -- and only the
remainder -- for whoever flushes next.
"""
while data.num_rows > 0:
if data.nbytes <= self.target_file_size:
self._write_data_to_file(data)
self._buffer.reset()
return
split_row = self._find_optimal_split_point(
data, self.target_file_size)
if split_row <= 0:
# Single row already exceeds target_file_size; nothing
# to gain from further slicing, write it as-is.
self._write_data_to_file(data)
self._buffer.reset()
return
self._write_data_to_file(data.slice(0, split_row))
data = data.slice(split_row)
self._buffer.reset(data)
def _merge_pending_by_pk(self, data: pa.Table) -> pa.Table:
"""Fold same-PK runs in ``data`` using ``self._merge_function``.
``data`` is required to already be sorted by
``(primary_key, sequence.field, _SEQUENCE_NUMBER)``. ``_flush_all`` is the
only caller and runs ``_sort_by_primary_key`` immediately
before this method, so the precondition holds.
NOTE(follow-up): the merge runs row-by-row over
``data.to_pydict()`` / ``pa.Table.from_pydict``. Arrow types
with non-trivial Python representations (Decimal128 with
specific precision/scale, timestamps with timezone or
sub-millisecond units, durations, deeply nested structs) can
drift across this round-trip. A columnar merge implementation
would close the gap and is tracked as a follow-up; until
then, partial-update on those types should be avoided in
pypaimon.
"""
n = data.num_rows
if n < 2:
# Single-row buffer cannot have duplicates; sidestep the
# row-by-row pyarrow round-trip in the common streaming case.
return data
col_names = data.schema.names
# ``to_pydict`` works on pyarrow >= 6 (Python 3.6 CI ships 6.0.1),
# unlike ``to_pylist`` which only landed in pyarrow 7.
col_dict = data.to_pydict()
rows = [{name: col_dict[name][i] for name in col_names}
for i in range(n)]
key_arity = len(self.trimmed_primary_keys)
# System fields sit at indices [key_arity, key_arity + 1] (the
# _SEQUENCE_NUMBER and _VALUE_KIND columns added by
# _add_system_fields). Everything to the right is the value side.
value_arity = len(col_names) - key_arity - 2
# Pool one ``KeyValue`` for the whole fold. Safe because:
# - ``DeduplicateMergeFunction.add`` stores the kv reference; the
# reused instance always carries the most recent ``replace``,
# which is exactly the "latest wins" the engine wants.
# - ``PartialUpdateMergeFunction.add`` also stores a reference,
# but ``get_result`` snapshots key + sequence into a fresh
# tuple before returning, so the consumed result is decoupled
# from any later ``replace`` on the pooled kv.
# - ``FirstRowMergeFunction.add`` ``copy()``s the first kv, so it
# keeps the first row rather than tracking later ``replace``s on
# the pooled kv (which would otherwise yield the last row).
# This drops per-row ``KeyValue``/``OffsetRow`` allocations and
# the resulting GC churn on large buffers.
pooled_kv = KeyValue(key_arity, value_arity)
merged_rows: List[dict] = []
i = 0
while i < n:
j = i
first_key = self._key_tuple(rows[i], col_names, key_arity)
while j < n and \
self._key_tuple(rows[j], col_names, key_arity) == first_key:
j += 1
run = rows[i:j]
self._merge_function.reset()
for r in run:
pooled_kv.replace(self._row_to_tuple(r, col_names))
self._merge_function.add(pooled_kv)
result_kv = self._merge_function.get_result()
if result_kv is not None:
merged_rows.append(
self._kv_to_row(result_kv, col_names,
key_arity, value_arity))
i = j
if not merged_rows:
return data.slice(0, 0)
result_dict = {name: [r[name] for r in merged_rows]
for name in data.schema.names}
return pa.Table.from_pydict(result_dict, schema=data.schema)
@staticmethod
def _key_tuple(row: dict, col_names: List[str], key_arity: int) -> tuple:
return tuple(row[col_names[i]] for i in range(key_arity))
@staticmethod
def _row_to_tuple(row: dict, col_names: List[str]) -> tuple:
return tuple(row[name] for name in col_names)
@staticmethod
def _kv_to_row(kv: KeyValue, col_names: List[str],
key_arity: int, value_arity: int) -> dict:
out = {}
for i in range(key_arity):
out[col_names[i]] = kv.key.get_field(i)
out[col_names[key_arity]] = kv.sequence_number
out[col_names[key_arity + 1]] = kv.value_row_kind_byte
for i in range(value_arity):
out[col_names[key_arity + 2 + i]] = kv.value.get_field(i)
return out
def _add_system_fields(self, data: pa.RecordBatch) -> pa.RecordBatch:
"""Add system fields: _KEY_{pk_key}, _SEQUENCE_NUMBER, _VALUE_KIND."""
num_rows = data.num_rows
from pypaimon.write.row_kind import write_value_schema
value_schema = write_value_schema(self.table, data.schema)
new_arrays = []
new_fields = []
for pk_key in self.trimmed_primary_keys:
if pk_key in data.schema.names:
key_column = data.column(pk_key)
new_arrays.append(key_column)
src_field = data.schema.field(pk_key)
new_fields.append(pa.field(f'_KEY_{pk_key}', src_field.type, nullable=src_field.nullable))
sequence_column = pa.array([self.sequence_generator.next() for _ in range(num_rows)], type=pa.int64())
new_arrays.append(sequence_column)
new_fields.append(pa.field('_SEQUENCE_NUMBER', pa.int64(), nullable=False))
from pypaimon.write.row_kind import row_kinds
value_kind_column = row_kinds(self.options, data)
new_arrays.append(value_kind_column)
new_fields.append(
pa.field(SpecialFields.VALUE_KIND.name, pa.int8(), nullable=False)
)
for i in range(data.num_columns):
if data.schema.field(i).name == SpecialFields.VALUE_KIND.name:
continue
new_arrays.append(data.column(i))
new_fields.append(value_schema.field(i))
return pa.RecordBatch.from_arrays(new_arrays, schema=pa.schema(new_fields))
def _sort_by_primary_key(
self, data: Union[pa.RecordBatch, pa.Table]
) -> Union[pa.RecordBatch, pa.Table]:
# pc.sort_indices + .take work uniformly over RecordBatch and
# Table, so this serves both the per-batch entry path (legacy)
# and the buffer-wide sort path (used by ``_flush_all``).
sort_columns = [data.column(key) for key in self.trimmed_primary_keys]
sort_orders = ['ascending'] * len(sort_columns)
sequence_fields = self.options.sequence_field()
if sequence_fields:
sequence_order = ('ascending' if self.options.sequence_field_sort_order_is_ascending()
else 'descending')
for field in sequence_fields:
column = data.column(field)
if pa.types.is_floating(column.type):
column = self._floating_sequence_sort_key(column)
sort_columns.append(column)
sort_orders.append(sequence_order)
if '_SEQUENCE_NUMBER' in data.schema.names:
sort_columns.append(data.column('_SEQUENCE_NUMBER'))
sort_orders.append('ascending')
# Sort a separate key table so temporary keys cannot collide with user
# column names or change the stored values (including signed zero).
names = [str(i) for i in range(len(sort_columns))]
# Java MergeTree comparators order null keys first. Keep Python-written files in the same
# order so their key ranges and sorted-run invariants are interoperable with Java readers.
sorted_indices = pc.sort_indices(
pa.table(sort_columns, names=names),
sort_keys=list(zip(names, sort_orders)), null_placement='at_start')
return data.take(sorted_indices)
@staticmethod
def _floating_sequence_sort_key(column):
"""Unsigned keys in Java Float/Double.compare order, preserving nulls."""
# The NumPy protocol supports Array and older ChunkedArray APIs alike.
values = np.asarray(column)
bits = values.view(np.dtype('uint{}'.format(column.type.bit_width)))
sign_bit = np.array(1 << (column.type.bit_width - 1), dtype=bits.dtype)
# Invert negative IEEE bits, flip the sign bit for nonnegative values.
# This orders -0.0 before +0.0, unlike Arrow's floating-point sort.
keys = np.where(bits & sign_bit, ~bits, bits ^ sign_bit)
# All NaN signs/payloads compare equal, above positive infinity.
keys[np.isnan(values)] = np.iinfo(bits.dtype).max
return pa.array(keys, mask=np.asarray(column.is_null()))