This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 80c5ab9593 [python] Align PK input changelogs and exercise Native
batch sharding (#10451)
80c5ab9593 is described below
commit 80c5ab959347bf46bb949d79d908cfdabffc8163
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 23:22:09 2026 +0800
[python] Align PK input changelogs and exercise Native batch sharding
(#10451)
---
.../pypaimon/tests/native_changelog_batch_test.py | 285 +++++++++++++++++++++
.../tests/native_cross_partition_write_test.py | 40 +++
.../tests/test_read_builder_nested_projection.py | 35 ++-
.../pypaimon/tests/test_write_merge_buffer.py | 2 +
.../pypaimon/tests/write/row_kind_write_test.py | 193 ++++++++++++++
.../pypaimon/tests/write/table_write_test.py | 11 +-
paimon-python/pypaimon/write/file_store_write.py | 2 +
paimon-python/pypaimon/write/native_write.py | 7 +-
paimon-python/pypaimon/write/row_kind.py | 21 +-
paimon-python/pypaimon/write/table_write.py | 19 +-
paimon-python/pypaimon/write/writer/data_writer.py | 124 ++++-----
.../pypaimon/write/writer/key_value_data_writer.py | 51 +++-
12 files changed, 713 insertions(+), 77 deletions(-)
diff --git a/paimon-python/pypaimon/tests/native_changelog_batch_test.py
b/paimon-python/pypaimon/tests/native_changelog_batch_test.py
new file mode 100644
index 0000000000..87b829cd06
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_changelog_batch_test.py
@@ -0,0 +1,285 @@
+# 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.
+
+"""Java batch changelog packing and sharding through the Native planner."""
+
+import json
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.scan_distribution import java_file_name_shard
+from pypaimon.schema.data_types import AtomicType, PyarrowFieldParser
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.internal_row import RowKind
+
+
+pytestmark = pytest.mark.native_plan
+SCHEMA = pa.schema([('key', pa.int64()), ('value', pa.int64())])
+
+
[email protected](params=[False, True], ids=['python', 'native'])
+def native(request):
+ return request.param
+
+
[email protected]
+def catalog(tmp_path):
+ result = CatalogFactory.create({'warehouse': str(tmp_path)})
+ result.create_database('default', True)
+ return result
+
+
+def _table(catalog, options=None, schema=SCHEMA, partitions=None):
+ config = {
+ 'bucket': '4', 'changelog-producer': 'input',
+ 'write.native.enabled': 'false',
+ 'source.split.target-size': '1mb', 'source.split.open-file-cost': '1b',
+ }
+ config.update(options or {})
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=list(partitions or []) + ['key'],
+ partition_keys=partitions, options=config), False)
+ return catalog.get_table('default.t')
+
+
+def _patch_snapshot(table, snapshot_id, **changes):
+ path = table.snapshot_manager().get_snapshot_path(snapshot_id)
+ data = json.loads(table.file_io.read_file_utf8(path))
+ data.update(changes)
+ table.file_io.write_file(path, json.dumps(data), overwrite=True)
+
+
+def _write(table, timestamp, rows, kind=None, overwrite=False):
+ builder = table.new_batch_write_builder()
+ if overwrite:
+ builder.overwrite()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ if kind is None:
+ # Use the declared Arrow types even for NULL nested values.
+ schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ else:
+ for row in rows:
+ writer.write_row(GenericRow(
+ [row[field.name] for field in table.fields], table.fields,
kind))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ snapshot_id = table.snapshot_manager().get_latest_snapshot().id
+ _patch_snapshot(table, snapshot_id, timeMillis=timestamp)
+ return snapshot_id
+
+
+def _read(table, native, window=(0, 300), shard=None, predicate=None,
+ projection=None, limit=None, with_stats=False):
+ builder = table.copy({
+ 'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower(),
+ 'scan.mode': 'incremental',
+ 'incremental-between-timestamp': '%s,%s' % window,
+ }).new_read_builder()
+ if predicate is not None:
+ builder.with_filter(predicate)
+ if projection is not None:
+ builder.with_projection(projection)
+ if limit is not None:
+ builder.with_limit(limit)
+ scan = builder.new_scan()
+ if shard is not None:
+ scan.with_shard(*shard)
+ with ExitStack() as stack:
+ if native:
+ for method in ('scan', 'scan_with_stats'):
+ stack.enter_context(patch.object(
+ scan.file_scanner, method,
+ side_effect=AssertionError('batch changelog planning used
Python')))
+ plan = scan.scan_with_stats()[0] if with_stats else scan.plan()
+ read = builder.new_read()
+ read.include_row_kind = True
+ with ExitStack() as stack:
+ if native:
+ assert all(getattr(split, '_native_split', None) is not None for
split in plan.splits())
+ stack.enter_context(patch.object(
+ read, '_create_split_read',
+ side_effect=AssertionError('batch changelog reading used
Python')))
+ rows = read.to_arrow(plan.splits(), parallelism=1).to_pylist()
+ return plan, rows
+
+
[email protected]('bucket', ['1', '4', '-1'])
[email protected]('dv', [False, True])
+def test_changelog_shards_cover_all_events_and_pack_the_whole_window(catalog,
native, bucket, dv):
+ table = _table(catalog, {'bucket': bucket, 'deletion-vectors.enabled':
str(dv).lower()})
+ expected = []
+ for version in range(3):
+ rows = [dict(key=key, value=version) for key in range(32)]
+ _write(table, (version + 1) * 100, rows)
+ expected.extend(('+I', row['key'], row['value']) for row in rows)
+ full, rows = _read(table, native)
+ assert sorted((row['_row_kind'], row['key'], row['value']) for row in
rows) == sorted(expected)
+ # Files from multiple commits in the same bucket must share batch splits.
+ assert len(full.splits()) == len({split.bucket for split in full.splits()})
+ full_files = {file.file_name for split in full.splits() for file in
split.files}
+ for count in (2, 5):
+ seen_files, union = set(), []
+ for index in range(count):
+ plan, rows = _read(table, native, shard=(index, count))
+ assert plan.snapshot_id == 3
+ assert all(split.is_streaming and split.snapshot_id == 3 for split
in plan.splits())
+ names = {file.file_name for split in plan.splits() for file in
split.files}
+ assert seen_files.isdisjoint(names)
+ seen_files.update(names)
+ union.extend((row['_row_kind'], row['key'], row['value']) for row
in rows)
+ for split in plan.splits():
+ if dv:
+ assert all(java_file_name_shard(file.file_name, count) ==
index for file in split.files)
+ else:
+ assert split.bucket % count == index
+ _, limited = _read(table, native, shard=(index, count), limit=1)
+ assert len(limited) == min(1, len(rows))
+ assert all(row in rows for row in limited)
+ assert seen_files == full_files
+ assert sorted(union) == sorted(expected)
+
+
[email protected]('native_write', [False, True])
+def test_changelog_keeps_all_row_kinds_and_snapshot_boundaries(catalog,
native, native_write):
+ table = _table(catalog, {'bucket': '1', 'write.native.enabled':
str(native_write).lower()})
+ kinds = [RowKind.INSERT, RowKind.UPDATE_AFTER, RowKind.UPDATE_BEFORE,
RowKind.DELETE]
+ for index, kind in enumerate(kinds):
+ _write(table, (index + 1) * 100, [dict(key=1, value=index)], kind=kind)
+ plan, rows = _read(table, native, window=(100, 400))
+ assert plan.snapshot_id == 4
+ assert len(plan.splits()) == 1
+ assert sorted((row['_row_kind'], row['key'], row['value']) for row in
rows) == [
+ ('+U', 1, 1), ('-D', 1, 3), ('-U', 1, 2)]
+ for window in [(100, 100), (400, 400), (500, 600)]:
+ plan, rows = _read(table, native, window=window, shard=(1, 3))
+ assert rows == []
+ assert plan.splits() == []
+
+
[email protected]('limit', [0, 1, 2, 5])
+def test_changelog_limit_is_applied_to_the_batch_once(catalog, native, limit):
+ table = _table(catalog, {'bucket': '1', 'source.split.target-size': '1b'})
+ for key in range(4):
+ _write(table, (key + 1) * 100, [dict(key=key, value=key * 10)])
+ plan, rows = _read(table, native, window=(0, 400), limit=limit)
+ assert plan.snapshot_id == 4
+ assert len(rows) == min(limit, 4)
+ if limit:
+ assert len(plan.splits()) == min(limit, 4)
+ assert all(row['_row_kind'] == '+I' for row in rows)
+
+
[email protected]('projection', [['value', 'key'], ['value'], []])
+def
test_sharded_changelog_filters_before_limit_and_preserves_projection(catalog,
native, projection):
+ table = _table(catalog, {'source.split.target-size': '1b'})
+ for version in range(3):
+ _write(table, (version + 1) * 100,
+ [dict(key=key, value=version) for key in range(32)])
+ predicate =
table.new_read_builder().new_predicate_builder().greater_than('value', 0)
+ total = 0
+ for index in range(5):
+ kwargs = dict(shard=(index, 5), predicate=predicate,
projection=projection)
+ _, rows = _read(table, native, **kwargs)
+ total += len(rows)
+ _, limited = _read(table, native, limit=1, **kwargs)
+ assert len(limited) == min(1, len(rows))
+ assert all(row in rows for row in limited)
+ assert all(list(row) == ['_row_kind'] + projection for row in limited)
+ if 'value' in projection:
+ assert all(row['value'] > 0 for row in limited)
+ assert total == 64
+
+
+def
test_changelog_skips_overwrite_but_includes_compact_and_keeps_empty_end_metadata(catalog,
native):
+ table = _table(catalog, {'bucket': '1'})
+ for key in range(4):
+ _write(table, (key + 1) * 100, [dict(key=key, value=key)])
+ _patch_snapshot(table, 2, commitKind='COMPACT')
+ _patch_snapshot(table, 3, commitKind='OVERWRITE')
+ _patch_snapshot(table, 4, changelogManifestList=None)
+ plan, rows = _read(table, native, window=(0, 400), with_stats=True)
+ assert plan.snapshot_id == 4
+ assert sorted((row['key'], row['value']) for row in rows) == [(0, 0), (1,
1)]
+ assert all(split.snapshot_id == 4 for split in plan.splits())
+ plan, rows = _read(table, native, window=(200, 400), shard=(0, 2))
+ assert plan.snapshot_id == 4
+ assert plan.splits() == []
+ assert rows == []
+
+
+def
test_changelog_partition_filter_and_nested_projection_remain_native(catalog,
native):
+ schema = SCHEMA.append(pa.field('part', pa.string())).append(pa.field(
+ 'payload', pa.struct([('score', pa.int64()), ('unused',
pa.string())])))
+ table = _table(catalog, schema=schema, partitions=['part'])
+ for version in range(3):
+ _write(table, (version + 1) * 100, [
+ dict(key=key, value=version, part=part,
+ payload=dict(score=version * 10 + key, unused='large'))
+ for part in ['keep', 'drop'] for key in range(8)])
+ predicate = table.new_read_builder().new_predicate_builder().equal('part',
'keep')
+ union = []
+ for index in range(3):
+ plan, rows = _read(table, native, shard=(index, 3),
predicate=predicate,
+ projection=['payload.score'])
+ assert plan.snapshot_id == 3
+ assert all(split.partition.get_field(0) == 'keep' for split in
plan.splits())
+ union.extend(row['payload_score'] for row in rows)
+ assert all(row['_row_kind'] == '+I' for row in rows)
+ assert sorted(union) == [version * 10 + key for version in range(3) for
key in range(8)]
+
+
[email protected]('engine', ['deduplicate', 'partial-update',
'aggregation'])
+def
test_batch_changelog_preserves_inputs_without_running_merge_engine(catalog,
native, engine):
+ options = {'bucket': '1', 'merge-engine': engine}
+ if engine == 'aggregation':
+ options['fields.value.aggregate-function'] = 'sum'
+ table = _table(catalog, options)
+ for version in range(3):
+ _write(table, (version + 1) * 100, [dict(key=1, value=version + 1)])
+ plan, rows = _read(table, native, shard=(0, 2))
+ assert len(plan.splits()) == 1
+ assert sorted((row['_row_kind'], row['key'], row['value']) for row in
rows) == [
+ ('+I', 1, 1), ('+I', 1, 2), ('+I', 1, 3)]
+
+
+def
test_batch_changelog_packs_evolved_schemas_and_filters_missing_fields(catalog,
native):
+ table = _table(catalog, {'bucket': '1'})
+ _write(table, 100, [dict(key=1, value=10)])
+ catalog.alter_table('default.t', [
+ SchemaChange.rename_column('value', 'renamed'),
+ SchemaChange.add_column('added', AtomicType('BIGINT'))], False)
+ table = catalog.get_table('default.t')
+ _write(table, 200, [dict(key=1, renamed=20, added=2)])
+ _write(table, 300, [dict(key=2, renamed=30, added=3)])
+ plan, rows = _read(table, native, projection=['key', 'renamed', 'added'])
+ assert len(plan.splits()) == 1
+ assert sorted((row['key'], row['renamed'], row['added']) for row in rows)
== [
+ (1, 10, None), (1, 20, 2), (2, 30, 3)]
+ predicate =
table.new_read_builder().new_predicate_builder().greater_than('added', 2)
+ _, rows = _read(table, native, shard=(0, 2), predicate=predicate,
+ projection=['renamed', 'added'], limit=1)
+ assert rows == [dict(_row_kind='+I', renamed=30, added=3)]
diff --git a/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
index edd9d963c7..de66a715d9 100644
--- a/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
+++ b/paimon-python/pypaimon/tests/native_cross_partition_write_test.py
@@ -23,6 +23,7 @@ import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.row_kind import RowKind
from pypaimon.write.native_write import NativeTableWrite
pytestmark = pytest.mark.native_plan
@@ -153,6 +154,45 @@ def test_cross_partition_rowkind_and_row_input(tmp_path):
assert _rows(table, native) == [('x', 'a', 1, 30)]
+def test_cross_partition_internal_row_kinds_and_migration_deletes(tmp_path):
+ table = _table(tmp_path, {'changelog-producer': 'input'})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ for values, kind in [
+ (['x', 'a', 1, 10], RowKind.INSERT),
+ (['x', 'b', 1, 20], RowKind.UPDATE_AFTER),
+ (['x', 'b', 1, 30], RowKind.UPDATE_BEFORE),
+ (['x', 'b', 1, 40], RowKind.DELETE),
+ ]:
+ writer.write_row(GenericRow(values, table.fields, kind))
+ assert writer._python_writer is None
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ for native in (False, True):
+ assert _rows(table, native) == []
+ builder = table.copy({
+ 'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower(),
+ 'scan.mode': 'incremental',
+ 'incremental-between-timestamp': '0,9223372036854775807',
+ }).new_read_builder()
+ plan = builder.new_scan().plan()
+ read = builder.new_read()
+ read.include_row_kind = True
+ if native:
+ with patch.object(read, '_create_split_read',
side_effect=AssertionError('fallback')):
+ rows = read.to_arrow(plan.splits(), parallelism=1).to_pylist()
+ else:
+ rows = read.to_arrow(plan.splits(), parallelism=1).to_pylist()
+ assert sorted((row['_row_kind'], row['p'], row['v']) for row in rows)
== [
+ ('+I', 'a', 10), ('+U', 'b', 20), ('-D', 'a', 20),
+ ('-D', 'b', 40), ('-U', 'b', 30)]
+
+
@pytest.mark.parametrize('committed', [False, True])
def
test_cross_partition_abort_preserves_prepared_and_committed_files(tmp_path,
committed):
table = _table(tmp_path)
diff --git
a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
index b5ccc8d108..1d7bd673ac 100644
--- a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
+++ b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
@@ -405,15 +405,40 @@ class
StreamReadBuilderNestedProjectionTest(_ReadBuilderTestBase):
batch._nested_name_paths(),
stream._nested_name_paths(),
)
- self.assertEqual(
- [field.name for field in batch.new_scan()._read_type],
- [field.name for field in stream.new_streaming_scan()._read_type],
- )
-
table_read = stream.with_include_row_kind().new_read()
self.assertEqual(batch._nested_name_paths(),
table_read.nested_name_paths)
self.assertTrue(table_read.include_row_kind)
+ # Exercise the configured scan through its public iterator. Native
+ # owns its read type in Rust and does not expose Python scanner fields.
+ write_builder = self.table.new_batch_write_builder()
+ writer, commit = write_builder.new_write(), write_builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pydict({
+ 'pk': [1, 2],
+ 'mv': [{'latest_version': 7, 'latest_value': 'unused'}, None],
+ 'val': ['unused', 'unused'],
+ 'attrs': [[('key.with.dots', 42), ('other', 99)], []],
+ }, schema=self.pa_schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ expected = {'mv_latest_version': [7, None],
+ 'attrs_key_with_dots': [42, None], 'pk': [1, 2]}
+ batch_plan = batch.new_scan().plan()
+ self.assertEqual(expected, batch.new_read().to_arrow(
+ batch_plan.splits(), parallelism=1).to_pydict())
+ iterator = stream.new_streaming_scan().stream_sync()
+ try:
+ stream_plan = next(iterator)
+ actual = table_read.to_arrow(stream_plan.splits(),
parallelism=1).to_pydict()
+ finally:
+ iterator.close()
+ self.assertEqual(['+I', '+I'], actual.pop('_row_kind'))
+ self.assertEqual(expected, actual)
+
if __name__ == '__main__':
unittest.main()
diff --git a/paimon-python/pypaimon/tests/test_write_merge_buffer.py
b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
index 42428e6388..d24c65cb73 100644
--- a/paimon-python/pypaimon/tests/test_write_merge_buffer.py
+++ b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
@@ -92,7 +92,9 @@ class _Harness(KeyValueDataWriter):
# trigger on its own in tests that don't care about rolling.
self.target_file_size = target_file_size
self._buffer = WriteBuffer(self._merge_data)
+ self._input_changelog_buffer = None
self.committed_files = []
+ self.committed_changelog_files = []
self.written_chunks = []
def _write_data_to_file(self, data):
diff --git a/paimon-python/pypaimon/tests/write/row_kind_write_test.py
b/paimon-python/pypaimon/tests/write/row_kind_write_test.py
new file mode 100644
index 0000000000..a8ba0fe6cc
--- /dev/null
+++ b/paimon-python/pypaimon/tests/write/row_kind_write_test.py
@@ -0,0 +1,193 @@
+# 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.
+
+
+"""InternalRow kinds survive the Arrow conversion used by both PK writers."""
+
+import pyarrow as pa
+import pytest
+from unittest.mock import patch
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.row.row_kind import RowKind
+from pypaimon.write.native_write import NativeTableWrite
+
+
[email protected](params=[False, pytest.param(True,
marks=pytest.mark.native_plan)],
+ ids=['python', 'native'])
+def native_write(request):
+ return request.param
+
+
+def _table(tmp_path, native_write, options=None, with_op=False):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ schema = pa.schema([('id', pa.int64()), ('value', pa.int64())])
+ if with_op:
+ schema = schema.append(pa.field('op', pa.string()))
+ config = {'bucket': '1', 'changelog-producer': 'input',
+ 'write.native.enabled': str(native_write).lower(),
+ 'scan.native-plan.enabled': 'false', 'read.native.enabled':
'false'}
+ config.update(options or {})
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ schema, primary_keys=['id'], options=config), False)
+ return catalog.get_table('default.t')
+
+
+def _write(table, rows, native_write, stream=False):
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ if native_write:
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ for values, kind in rows:
+ writer.write_row(GenericRow(values, table.fields, kind))
+ if native_write:
+ assert writer._python_writer is None
+ if stream:
+ commit.commit(writer.prepare_commit(1), 1)
+ else:
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _rows(table, changelog=False):
+ if changelog:
+ table = table.copy({'scan.mode': 'incremental',
+ 'incremental-between-timestamp':
'0,9223372036854775807'})
+ builder = table.new_read_builder()
+ reader = builder.new_read()
+ reader.include_row_kind = changelog
+ result = reader.to_arrow(builder.new_scan().plan().splits(),
parallelism=1).to_pydict()
+ return sorted(zip(*(result[name] for name in result)))
+
+
[email protected]('stream', [False, True])
[email protected]('bucket', ['1', '-1'])
+def test_internal_row_kinds_reach_data_and_input_changelog(tmp_path,
native_write, stream, bucket):
+ table = _table(tmp_path, native_write, {'bucket': bucket})
+ _write(table, [
+ ([1, 10], RowKind.INSERT), ([1, 20], RowKind.UPDATE_AFTER),
+ ([2, 30], RowKind.DELETE), ([3, 40], RowKind.UPDATE_BEFORE),
+ ([4, 50], RowKind.INSERT)], native_write, stream)
+ assert _rows(table) == [(1, 20), (4, 50)]
+ assert _rows(table, True) == [
+ ('+I', 1, 10), ('+I', 4, 50), ('+U', 1, 20),
+ ('-D', 2, 30), ('-U', 3, 40)]
+
+
[email protected]('options,expected', [
+ ({'ignore-delete': 'true'}, [('+I', 1, 10), ('+U', 3, 40)]),
+ ({'ignore-update-before': 'true'}, [('+I', 1, 10), ('+U', 3, 40), ('-D',
1, 20)]),
+])
+def test_internal_row_kinds_are_filtered_before_dynamic_bucket_assignment(
+ tmp_path, native_write, options, expected):
+ options = dict(options, bucket='-1')
+ table = _table(tmp_path, native_write, options)
+ _write(table, [
+ ([1, 10], RowKind.INSERT), ([1, 20], RowKind.DELETE),
+ ([2, 30], RowKind.UPDATE_BEFORE), ([3, 40], RowKind.UPDATE_AFTER)],
native_write)
+ assert _rows(table, True) == expected
+
+
+def test_rowkind_field_overrides_internal_row_kind(tmp_path, native_write):
+ table = _table(tmp_path, native_write, {'rowkind.field': 'op'},
with_op=True)
+ _write(table, [
+ ([1, 10, '+I'], RowKind.DELETE),
+ ([2, 20, '-D'], RowKind.INSERT)], native_write)
+ assert _rows(table) == [(1, 10, '+I')]
+ assert _rows(table, True) == [('+I', 1, 10, '+I'), ('-D', 2, 20, '-D')]
+
+
[email protected]('row_first', [False, True])
[email protected]('producer', ['none', 'input'])
+def test_mixed_arrow_and_row_writes_preserve_kinds(tmp_path, native_write,
row_first, producer):
+ table = _table(tmp_path, native_write, {'changelog-producer': producer})
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ schema = pa.schema([('id', pa.int64()), ('value', pa.int64())])
+ try:
+ if row_first:
+ writer.write_row(GenericRow([1, 10], table.fields, RowKind.INSERT))
+ writer.write_arrow(pa.Table.from_pydict(
+ {'id': [1], 'value': [20]}, schema=schema))
+ else:
+ writer.write_arrow(pa.Table.from_pydict(
+ {'id': [1], 'value': [10]}, schema=schema))
+ writer.write_row(GenericRow([1, 20], table.fields, RowKind.DELETE))
+ if native_write:
+ assert isinstance(writer, NativeTableWrite)
+ assert writer._python_writer is None
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ assert _rows(table) == ([(1, 20)] if row_first else [])
+ if producer == 'input':
+ assert _rows(table, True) == [('+I', 1, 10), ('+I' if row_first else
'-D', 1, 20)]
+
+
[email protected]('failure', ['later_changelog',
'data_after_changelogs'])
+def
test_input_changelog_retry_preserves_published_chunks_and_all_inputs(tmp_path,
failure):
+ table = _table(tmp_path, False)
+ builder = table.new_stream_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pydict(
+ {'id': [1, 1, 1], 'value': [10, 20, 30]},
+ schema=pa.schema([('id', pa.int64()), ('value', pa.int64())])))
+ # Delay rolling until prepare so a retry can preserve already published
+ # log chunks. These rows fold into one data row, but remain three logs.
+ bucket_writer =
next(iter(writer.file_store_write.data_writers.values()))
+ bucket_writer.target_file_size = 1
+ original = table.file_io.write_parquet
+ state = {'changelogs': 0, 'failed': False}
+
+ def fail_once(path, data, **kwargs):
+ if '/changelog-' in str(path):
+ state['changelogs'] += 1
+ should_fail = failure == 'later_changelog' and
state['changelogs'] == 2
+ else:
+ should_fail = failure == 'data_after_changelogs'
+ if should_fail and not state['failed']:
+ state['failed'] = True
+ raise IOError('transient file write failure')
+ return original(path, data, **kwargs)
+
+ with patch.object(table.file_io, 'write_parquet',
side_effect=fail_once):
+ with pytest.raises(IOError, match='transient file write failure'):
+ writer.prepare_commit(1)
+ messages = writer.prepare_commit(1)
+ logs = [meta for message in messages for meta in
message.changelog_files]
+ assert len(logs) == 3
+ assert sum(meta.row_count for meta in logs) == 3
+ assert [(meta.min_sequence_number, meta.max_sequence_number) for meta
in logs] == [(0, 0), (1, 1), (2, 2)]
+ paths = [meta.physical_path() for meta in logs]
+ assert all(table.file_io.exists(path) for path in paths)
+ commit.commit(messages, 1)
+ assert _rows(table) == [(1, 30)]
+ assert _rows(table, True) == [('+I', 1, 10), ('+I', 1, 20), ('+I', 1,
30)]
+ assert writer.prepare_commit(2) == []
+ finally:
+ writer.close()
+ commit.close()
+ # Successful prepare transfers file ownership to the messages. Closing
+ # the writer must retain the logs even after an earlier failed prepare.
+ assert all(table.file_io.exists(path) for path in paths)
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 5b48d2ef3b..8aa93a53ad 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -225,9 +225,10 @@ class TableWriteTest(unittest.TestCase):
return out
@staticmethod
- def _mock_table_write(partitions, buckets):
+ def _mock_table_write(partitions, buckets, schema):
table_write = object.__new__(TableWrite)
table_write.table = Mock(is_primary_key_table=False)
+ table_write.table_pyarrow_schema = schema
table_write._validate_pyarrow_schema = Mock()
table_write.row_key_extractor = Mock()
table_write.file_store_write = Mock()
@@ -242,7 +243,7 @@ class TableWriteTest(unittest.TestCase):
'payload': [b'a', b'b'],
})
table_write = self._mock_table_write(
- [('p1',), ('p1',)], [0, 0])
+ [('p1',), ('p1',)], [0, 0], data.schema)
with patch.object(pa.compute, 'take', wraps=pa.compute.take) as take:
table_write.write_arrow_batch(data)
@@ -258,7 +259,7 @@ class TableWriteTest(unittest.TestCase):
})
table_write = self._mock_table_write(
[('p1',), ('p1',), ('p2',), ('p2',)],
- [0, 0, 1, 1])
+ [0, 0, 1, 1], data.schema)
with patch.object(pa.compute, 'take', wraps=pa.compute.take) as take:
table_write.write_arrow_batch(data)
@@ -280,7 +281,7 @@ class TableWriteTest(unittest.TestCase):
})
table_write = self._mock_table_write(
[('p1',), ('p2',), ('p1',), ('p2',)],
- [0, 1, 0, 1])
+ [0, 1, 0, 1], data.schema)
with patch.object(pa.compute, 'take', wraps=pa.compute.take) as take:
table_write.write_arrow_batch(data)
@@ -303,6 +304,7 @@ class TableWriteTest(unittest.TestCase):
})
table_write = object.__new__(TableWrite)
table_write.table = Mock(is_primary_key_table=False)
+ table_write.table_pyarrow_schema = data.schema
table_write._validate_pyarrow_schema = Mock()
table_write.file_store_write = Mock()
table_write.row_key_extractor = Mock()
@@ -328,6 +330,7 @@ class TableWriteTest(unittest.TestCase):
})
table_write = object.__new__(TableWrite)
table_write.table = Mock(is_primary_key_table=False)
+ table_write.table_pyarrow_schema = data.schema
table_write._validate_pyarrow_schema = Mock()
table_write.file_store_write = Mock()
table_write.row_key_extractor = Mock()
diff --git a/paimon-python/pypaimon/write/file_store_write.py
b/paimon-python/pypaimon/write/file_store_write.py
index 16290ff864..a7bc72db8b 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -121,6 +121,8 @@ class FileStoreWrite:
self.table.table_schema.fields,
column_names,
)
+ from pypaimon.write.row_kind import with_row_kind
+ data = with_row_kind(self.table, data, row)
writer.write(data.to_batches()[0])
def roll_before_group_if_needed(self, row_count: int):
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index bdc7a98518..0a8d0f136f 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -206,6 +206,9 @@ class NativeTableWrite:
if self._python_writer is not None:
return self._python_writer.write_arrow_batch(data, bucket)
data = self._prepare_native_arrow_data(data)
+ self._write_native_batch(data, bucket)
+
+ def _write_native_batch(self, data, bucket=None):
if data.num_rows:
# A failed native write may already have produced files. Never
# retry that batch through Python after this point.
@@ -243,8 +246,10 @@ class NativeTableWrite:
return self._switch_to_python().write_row(row)
values = row_to_named_values(row, self.table.table_schema.fields)
names = self._schema.names
- self.write_arrow_batch(row_values_to_arrow_table(
+ data = self._prepare_native_arrow_data(row_values_to_arrow_table(
values, self.table.table_schema.fields, names).to_batches()[0])
+ from pypaimon.write.row_kind import with_row_kind
+ self._write_native_batch(with_row_kind(self.table, data, row))
def prepare_commit(self, commit_identifier=None):
if self._python_writer is not None:
diff --git a/paimon-python/pypaimon/write/row_kind.py
b/paimon-python/pypaimon/write/row_kind.py
index 9e501e6565..78b5bc5e92 100644
--- a/paimon-python/pypaimon/write/row_kind.py
+++ b/paimon-python/pypaimon/write/row_kind.py
@@ -24,6 +24,8 @@ from pypaimon.table.row.row_kind import RowKind
def row_kinds(options, data):
name = options.options.to_map().get('rowkind.field')
if name is None:
+ if '_VALUE_KIND' in data.schema.names:
+ return data.column('_VALUE_KIND').to_pylist()
return [0] * data.num_rows
if name not in data.schema.names:
raise ValueError('Cannot find rowkind field %s in table schema' % name)
@@ -49,15 +51,26 @@ def filter_write_batch(table, data):
return data.filter(pa.array(keep, type=pa.bool_()))
-def skip_write_row(table, values):
+def with_row_kind(table, data, row):
+ """Carry an InternalRow's kind through the internal Arrow write path.
+
+ A configured rowkind.field takes precedence, as in Java RowKindGenerator.
+ Public Arrow writes still accept only the declared table/write schema.
+ """
+ if table.is_primary_key_table and
table.options.options.to_map().get('rowkind.field') is None:
+ return data.append_column(
+ pa.field('_VALUE_KIND', pa.int8(), nullable=False),
+ pa.array([row.get_row_kind().value] * data.num_rows,
type=pa.int8()))
+ return data
+
+
+def skip_write_row(table, values, row_kind=RowKind.INSERT):
if not table.is_primary_key_table:
return False
options = table.options
raw = options.options.to_map()
name = raw.get('rowkind.field')
- if name is None:
- return False
- kind = RowKind.from_string(values[name]).value
+ kind = row_kind.value if name is None else
RowKind.from_string(values[name]).value
return _is_filtered(kind, options.ignore_delete(),
str(raw.get('ignore-update-before', 'false')).lower()
== 'true')
diff --git a/paimon-python/pypaimon/write/table_write.py
b/paimon-python/pypaimon/write/table_write.py
index 04e7b4df01..44909ea45a 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -159,7 +159,7 @@ class TableWrite:
require_columns(values_by_name, column_names, "write_row")
require_columns(values_by_name, self.table.partition_keys, "write_row")
from pypaimon.write.row_kind import skip_write_row
- if skip_write_row(self.table, values_by_name):
+ if skip_write_row(self.table, values_by_name, row.get_row_kind()):
return
partition, bucket = (
self.row_key_extractor.extract_partition_bucket_row(values_by_name)
@@ -314,9 +314,26 @@ class TableWrite:
table = getattr(self, 'table', None)
if table is None:
return data
+ data = self._normalize_arrow_write_schema(data)
from pypaimon.write.row_kind import filter_write_batch
return filter_write_batch(table, data)
+ def _normalize_arrow_write_schema(self, data):
+ # Compatible input batches may differ in top-level nullability or
+ # metadata. Buffered writes and row-to-Arrow conversion must use the
+ # same declared fields, while keeping normalized Arrow array types.
+ fields = []
+ for field in data.schema:
+ declared = self.table_pyarrow_schema.field(field.name)
+ fields.append(pa.field(field.name, field.type,
nullable=declared.nullable,
+ metadata=declared.metadata))
+ schema = pa.schema(fields, metadata=self.table_pyarrow_schema.metadata)
+ if data.schema.equals(schema, check_metadata=True):
+ return data
+ if isinstance(data, pa.RecordBatch):
+ return pa.RecordBatch.from_arrays(data.columns, schema=schema)
+ return pa.Table.from_arrays(data.columns, schema=schema)
+
def _validate_pyarrow_schema(self, data_schema: pa.Schema):
if self._is_compatible_pyarrow_schema(data_schema,
self.table_pyarrow_schema):
return
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py
b/paimon-python/pypaimon/write/writer/data_writer.py
index 4d78940cdd..d6ded1e0f0 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -310,70 +310,26 @@ class DataWriter(ABC):
zstd_level=self.zstd_level)
extra_files.append(row_sidecar_name)
- # min key & max key
-
- selected_table = logical_data.select(self.trimmed_primary_keys)
- key_columns_batch = selected_table.to_batches()[0]
- min_key_row_batch = key_columns_batch.slice(0, 1)
- max_key_row_batch =
key_columns_batch.slice(key_columns_batch.num_rows - 1, 1)
- min_key = [col.to_pylist()[0] for col in min_key_row_batch.columns]
- max_key = [col.to_pylist()[0] for col in max_key_row_batch.columns]
-
- # key stats & value stats
- value_stats_enabled = self.options.metadata_stats_enabled()
- if value_stats_enabled:
- stats_fields = self.table.fields if
self.table.is_primary_key_table \
- else
PyarrowFieldParser.to_paimon_schema(logical_data.schema)
- else:
- stats_fields = self.table.trimmed_primary_keys_fields
- column_stats = {
- field.name: self._get_column_stats(logical_data, field.name)
- for field in stats_fields
- }
- key_fields = self.trimmed_primary_keys_fields
- key_stats = self._collect_value_stats(
- logical_data, key_fields, column_stats)
- if not self.options.primary_key_nullable() and not all(
- count == 0 for count in key_stats.null_counts):
- raise RuntimeError("Primary key should not be null")
-
- value_fields = stats_fields if value_stats_enabled else []
- value_stats = self._collect_value_stats(
- logical_data, value_fields, column_stats)
-
- # Read the range without advancing it: the advance belongs with the
- # append below, so a retried flush derives the same range.
- if self.table.is_primary_key_table and '_SEQUENCE_NUMBER' in
data.schema.names:
- # PK files can contain a deduplicated or split subset of the
- # buffer. Java records the range of the rows actually written.
- sequences = data.column('_SEQUENCE_NUMBER')
- min_seq = pc.min(sequences).as_py()
- max_seq = pc.max(sequences).as_py()
- else:
- min_seq = self.sequence_generator.start
- max_seq = self.sequence_generator.current
- creation_time = Timestamp.now()
+ statistics = self._data_file_statistics(logical_data)
data_meta = self._create_data_file_meta(
file_name=file_name,
file_path=file_path,
row_count=data.num_rows,
- min_key=GenericRow(min_key, self.trimmed_primary_keys_fields),
- max_key=GenericRow(max_key, self.trimmed_primary_keys_fields),
- key_stats=key_stats,
- value_stats=value_stats,
- min_sequence_number=min_seq,
- max_sequence_number=max_seq,
+ min_key=GenericRow(statistics['min_key'],
self.trimmed_primary_keys_fields),
+ max_key=GenericRow(statistics['max_key'],
self.trimmed_primary_keys_fields),
+ key_stats=statistics['key_stats'],
+ value_stats=statistics['value_stats'],
+ min_sequence_number=statistics['min_seq'],
+ max_sequence_number=statistics['max_seq'],
extra_files=extra_files,
- creation_time=creation_time,
+ creation_time=statistics['creation_time'],
delete_row_count=self._count_delete_rows(data),
)
- if self.changelog_producer == ChangelogProducer.INPUT:
+ if (self.changelog_producer == ChangelogProducer.INPUT
+ and not self._writes_changelog_before_merge()):
changelog_meta = self._write_changelog_file(
- data, min_key, max_key, key_stats, value_stats,
- min_seq, max_seq, creation_time,
- value_stats_enabled, external_path_str is not None,
- )
+ data, is_external=external_path_str is not None,
**statistics)
except Exception:
self.file_io.delete_quietly(file_path)
if row_sidecar_path is not None:
@@ -382,6 +338,57 @@ class DataWriter(ABC):
self._finish_data_file(data_meta, changelog_meta,
shared_shredding_stats)
+ def _data_file_statistics(self, data):
+ # min key & max key
+
+ selected_table = data.select(self.trimmed_primary_keys)
+ key_columns_batch = selected_table.to_batches()[0]
+ min_key_row_batch = key_columns_batch.slice(0, 1)
+ max_key_row_batch = key_columns_batch.slice(key_columns_batch.num_rows
- 1, 1)
+ min_key = [col.to_pylist()[0] for col in min_key_row_batch.columns]
+ max_key = [col.to_pylist()[0] for col in max_key_row_batch.columns]
+
+ # key stats & value stats
+ value_stats_enabled = self.options.metadata_stats_enabled()
+ if value_stats_enabled:
+ stats_fields = self.table.fields if
self.table.is_primary_key_table \
+ else PyarrowFieldParser.to_paimon_schema(data.schema)
+ else:
+ stats_fields = self.table.trimmed_primary_keys_fields
+ column_stats = {
+ field.name: self._get_column_stats(data, field.name)
+ for field in stats_fields
+ }
+ key_fields = self.trimmed_primary_keys_fields
+ key_stats = self._collect_value_stats(
+ data, key_fields, column_stats)
+ if not self.options.primary_key_nullable() and not all(
+ count == 0 for count in key_stats.null_counts):
+ raise RuntimeError("Primary key should not be null")
+
+ value_fields = stats_fields if value_stats_enabled else []
+ value_stats = self._collect_value_stats(
+ data, value_fields, column_stats)
+
+ # Read the range without advancing it: the advance belongs with the
+ # append below, so a retried flush derives the same range.
+ if self.table.is_primary_key_table and '_SEQUENCE_NUMBER' in
data.schema.names:
+ # PK files can contain a deduplicated or split subset of the
+ # buffer. Java records the range of the rows actually written.
+ sequences = data.column('_SEQUENCE_NUMBER')
+ min_seq = pc.min(sequences).as_py()
+ max_seq = pc.max(sequences).as_py()
+ else:
+ min_seq = self.sequence_generator.start
+ max_seq = self.sequence_generator.current
+ creation_time = Timestamp.now()
+ return dict(min_key=min_key, max_key=max_key, key_stats=key_stats,
+ value_stats=value_stats, min_seq=min_seq, max_seq=max_seq,
+ creation_time=creation_time,
value_stats_enabled=value_stats_enabled)
+
+ def _writes_changelog_before_merge(self):
+ return False
+
def _finish_data_file(self, data_meta, changelog_meta=None,
shared_shredding_stats=None):
"""Record a fully written data file and its optional changelog."""
@@ -459,11 +466,10 @@ class DataWriter(ABC):
def _write_changelog_file(self, data, min_key, max_key, key_stats,
value_stats,
min_seq, max_seq, creation_time,
value_stats_enabled, is_external) ->
DataFileMeta:
- """Write the changelog file for one data file and return its meta.
+ """Write a complete changelog file and return its metadata.
- The caller appends the returned meta only once the whole data file has
- landed, so a failure here leaves nothing behind: no meta to commit, and
- no file on disk either.
+ The caller publishes this metadata after success. A failure here leaves
+ neither metadata to commit nor a partial changelog file on disk.
"""
cl_fmt = self.changelog_file_format
changelog_file_name = f"changelog-{uuid.uuid4()}-0.{cl_fmt}"
diff --git a/paimon-python/pypaimon/write/writer/key_value_data_writer.py
b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
index 9e9998cd3d..b2e79d50d0 100644
--- a/paimon-python/pypaimon/write/writer/key_value_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
@@ -27,6 +27,7 @@ from pypaimon.read.reader.deduplicate_merge_function import \
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.table.row.key_value import KeyValue
from pypaimon.write.writer.data_writer import DataWriter
+from pypaimon.write.writer.write_buffer import WriteBuffer
class KeyValueDataWriter(DataWriter):
@@ -50,6 +51,16 @@ class KeyValueDataWriter(DataWriter):
# 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
@@ -57,7 +68,10 @@ class KeyValueDataWriter(DataWriter):
# 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])
+ 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
@@ -65,7 +79,7 @@ class KeyValueDataWriter(DataWriter):
return pa.concat_tables([existing_data, new_data])
def prepare_commit(self) -> List[DataFileMeta]:
- if self._buffer.num_rows > 0:
+ 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``.
@@ -88,7 +102,7 @@ class KeyValueDataWriter(DataWriter):
# ``_flush_all`` so the contract holds even on the
# close-without-prepare_commit path.
try:
- if self._buffer.num_rows > 0:
+ if self._buffer.num_rows > 0 or
self._has_pending_input_changelog():
self._flush_all()
except Exception as e:
import logging
@@ -109,6 +123,7 @@ class KeyValueDataWriter(DataWriter):
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()
@@ -124,6 +139,34 @@ class KeyValueDataWriter(DataWriter):
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.
@@ -275,6 +318,8 @@ class KeyValueDataWriter(DataWriter):
new_fields.append(pa.field('_VALUE_KIND', pa.int8(), nullable=False))
for i in range(data.num_columns):
+ if data.schema.field(i).name == '_VALUE_KIND':
+ continue
new_arrays.append(data.column(i))
new_fields.append(data.schema.field(i))