blob: 5200dd2078319cad02783d58cf6ace7ee61b1030 [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 pyarrow as pa
import pyarrow.compute as pc
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.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.write.writer.data_writer import DataWriter
class KeyValueDataWriter(DataWriter):
"""Data writer for primary key tables with system fields and sorting.
Accumulates incoming batches in ``pending_data`` 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()
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. ``pending_data`` ends
# up as a concat of unsorted batches; ``_flush_all`` sorts it
# exactly once before folding.
enhanced_data = self._add_system_fields(data)
return pa.Table.from_batches([enhanced_data])
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.pending_data is not None and self.pending_data.num_rows > 0:
self._flush_all()
# ``_flush_all`` leaves ``pending_data = None``, 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, we never keep a slice remainder in
# ``pending_data`` -- flush empties the buffer outright.
if (self.pending_data is not None
and self.pending_data.num_rows > 0
and self.pending_data.nbytes > self.target_file_size):
self._flush_all()
def close(self):
# Override the base ``close`` because its straight
# ``_write_data_to_file(pending_data)`` would land an unsorted,
# un-folded buffer 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.pending_data is not None and self.pending_data.num_rows > 0:
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.pending_data = None
def _flush_all(self) -> None:
"""Sort + fold the entire buffer, then roll-write as files.
On return, ``pending_data is None`` and every flushed chunk
has been recorded in ``committed_files``. The buffer is
always fully drained per flush: no slice remainder is
carried back into ``pending_data``.
"""
if self.pending_data is None or self.pending_data.num_rows == 0:
self.pending_data = None
return
sorted_data = self._sort_by_primary_key(self.pending_data)
folded = self._merge_pending_by_pk(sorted_data)
self.pending_data = None
if folded.num_rows == 0:
return
self._roll_write(folded)
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.
"""
while data.num_rows > 0:
if data.nbytes <= self.target_file_size:
self._write_data_to_file(data)
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)
return
self._write_data_to_file(data.slice(0, split_row))
data = data.slice(split_row)
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_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
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))
# TODO: support real row kind here
value_kind_column = pa.array([0] * num_rows, type=pa.int8())
new_arrays.append(value_kind_column)
new_fields.append(pa.field('_VALUE_KIND', pa.int8(), nullable=False))
for i in range(data.num_columns):
new_arrays.append(data.column(i))
new_fields.append(data.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_keys = [(key, 'ascending') for key in self.trimmed_primary_keys]
if '_SEQUENCE_NUMBER' in data.schema.names:
sort_keys.append(('_SEQUENCE_NUMBER', 'ascending'))
sorted_indices = pc.sort_indices(data, sort_keys=sort_keys)
return data.take(sorted_indices)