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 c3930a78bd [python] Expose row-id updater through TableUpdate (#10188)
c3930a78bd is described below
commit c3930a78bdcfcb424c4f425d945b6e512b5a7d92
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Sep 26 15:09:29 2026 +0800
[python] Expose row-id updater through TableUpdate (#10188)
---
paimon-python/README.md | 21 ++++
paimon-python/pypaimon/multimodal/blob_store.py | 10 +-
.../pypaimon/ray/data_evolution_merge_join.py | 41 ++-----
.../pypaimon/table/data_evolution_merge_into.py | 10 +-
paimon-python/pypaimon/tests/blob_table_test.py | 19 +---
paimon-python/pypaimon/tests/table_update_test.py | 39 ++++++-
.../pypaimon/tests/table_upsert_by_key_test.py | 9 +-
paimon-python/pypaimon/tests/vector_table_test.py | 20 +---
.../write/commit/row_id_conflict_rewriter.py | 15 ++-
paimon-python/pypaimon/write/row_id_file_index.py | 121 +++++++++++++++++++++
paimon-python/pypaimon/write/table_update.py | 39 +++++--
.../pypaimon/write/table_update_by_row_id.py | 111 ++-----------------
.../pypaimon/write/table_upsert_by_key.py | 16 +--
paimon-python/pypaimon/write/write_builder.py | 23 ++++
14 files changed, 286 insertions(+), 208 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index d83b947518..f1654fa0e7 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -105,6 +105,27 @@ pip3 install dist/*.tar.gz
The command will install the package and core dependencies to your local
Python environment.
+# Row ID column updates
+
+For a batch update of selected columns in a data-evolution table with row
+tracking, pass an Arrow table containing `_ROW_ID` and the columns to update.
+Create the updater and committer from the same builder so they share a commit
+user:
+
+```python
+builder = table.new_batch_write_builder()
+updater = builder.new_update().new_update_by_row_id()
+messages = updater.update_columns(updates, ["name"])
+commit = builder.new_commit()
+try:
+ commit.commit(messages)
+finally:
+ commit.close()
+```
+
+For stream updates, use `table.new_stream_write_builder()` and pass the stream
+commit identifier to `new_update().new_update_by_row_id(commit_identifier)`.
+
# Parquet page-index reads
For row-tracking tables with a Parquet OffsetIndex, PyPaimon can read a
diff --git a/paimon-python/pypaimon/multimodal/blob_store.py
b/paimon-python/pypaimon/multimodal/blob_store.py
index 53831750b5..061402ba87 100644
--- a/paimon-python/pypaimon/multimodal/blob_store.py
+++ b/paimon-python/pypaimon/multimodal/blob_store.py
@@ -24,11 +24,9 @@ from typing import BinaryIO, Dict, Iterable, List, Mapping,
Optional, Sequence
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.schema.data_types import is_blob_type
-from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.row.blob import Blob, BlobData, BlobDescriptor
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
_RANGE_PATTERN = re.compile(r"^bytes=(\d*)-(\d*)$")
@@ -318,11 +316,9 @@ class BlobStore:
write_builder = self._raw_table.new_batch_write_builder()
table_commit = write_builder.new_commit()
try:
- messages = TableUpdateByRowId(
- self._raw_table,
- write_builder.commit_user,
- BATCH_COMMIT_IDENTIFIER,
- ).update_rows_columns(rows, row_ids_by_row, update_columns)
+ updater = write_builder.new_update().new_update_by_row_id()
+ messages = updater.update_rows_columns(
+ rows, row_ids_by_row, update_columns)
if messages:
table_commit.commit(messages)
finally:
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 835c9a981b..b86ac7b363 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -345,9 +345,8 @@ def _self_merge_aliases(batch: pa.Table, row_id_name: str)
-> pa.Table:
def _apply_self_merge_update_group(context, file_group, collect_row_ids):
"""Read, transform, and stage one complete first-row-id file group."""
from pypaimon.read.table_read import TableRead
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.file_store_commit import _abort_commit_messages
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+ from pypaimon.write.row_id_file_index import RowIdFileIndex
table_read = TableRead(
context.scan_table,
@@ -387,16 +386,11 @@ def _apply_self_merge_update_group(context, file_group,
collect_row_ids):
if collect_row_ids else []
)
- import uuid
- files_info = TableUpdateByRowId._files_info_from_splits(
+ files_info = RowIdFileIndex.from_splits(
context.snapshot_id, [file_group],
)
- updater = TableUpdateByRowId(
- context.table,
- "_self_merge_group_" + uuid.uuid4().hex[:8],
- BATCH_COMMIT_IDENTIFIER,
- _precomputed_files_info=files_info,
- )
+ updater =
context.table.new_batch_write_builder().new_update().new_update_by_row_id(
+ _precomputed_files_info=files_info)
try:
messages = updater.update_columns(updates, context.update_cols)
except Exception:
@@ -698,14 +692,11 @@ def distributed_update_apply(
) -> Tuple[list, int, list]:
import numpy as np
import pickle
- import uuid
import pyarrow.compute as pc
import ray
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.special_fields import SpecialFields
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
row_id_name = SpecialFields.ROW_ID.name
cols = list(write_update_cols)
@@ -724,11 +715,7 @@ def distributed_update_apply(
table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
if base_snapshot_id is not None else table
)
- planner = TableUpdateByRowId(
- scan_table,
- "_merge_into_planner_" + uuid.uuid4().hex[:8],
- BATCH_COMMIT_IDENTIFIER,
- )
+ planner =
scan_table.new_batch_write_builder().new_update().new_update_by_row_id()
sorted_first_row_ids = list(planner.first_row_ids)
if not sorted_first_row_ids:
return [], 0, []
@@ -843,12 +830,9 @@ def distributed_update_apply(
for_update.column(row_id_name).to_pylist()
if collect_row_ids else []
)
- worker = TableUpdateByRowId(
- captured_table,
- "_merge_into_shard_" + uuid.uuid4().hex[:8],
- BATCH_COMMIT_IDENTIFIER,
- _precomputed_files_info=ray.get(precomputed_info_ref),
- )
+ update = captured_table.new_batch_write_builder().new_update()
+ worker = update.new_update_by_row_id(
+ _precomputed_files_info=ray.get(precomputed_info_ref))
msgs = worker.update_columns(for_update, list(captured_cols))
return pa.Table.from_pydict({
"msgs_blob": [pickle.dumps(msgs)],
@@ -915,16 +899,13 @@ def distributed_read_by_row_id(
is empty. Read-side mirror of ``distributed_update_apply``.
"""
import numpy as np
- import uuid
import ray
from pypaimon.globalindex.indexed_split import IndexedSplit
from pypaimon.read.split import DataSplit
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
row_id_name = SpecialFields.ROW_ID.name
read_cols = list(projection)
@@ -935,11 +916,7 @@ def distributed_read_by_row_id(
empty_out = _read_output_schema(table, read_cols).empty_table()
# The caller pinned the resolved snapshot, including any retained tag
metadata.
- planner = TableUpdateByRowId(
- table,
- "_read_by_row_id_planner_" + uuid.uuid4().hex[:8],
- BATCH_COMMIT_IDENTIFIER,
- )
+ planner =
table.new_batch_write_builder().new_update().new_update_by_row_id()
sorted_first_row_ids = list(planner.first_row_ids)
if not sorted_first_row_ids:
return None
diff --git a/paimon-python/pypaimon/table/data_evolution_merge_into.py
b/paimon-python/pypaimon/table/data_evolution_merge_into.py
index 7439823d08..58c02d4517 100644
--- a/paimon-python/pypaimon/table/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/table/data_evolution_merge_into.py
@@ -55,7 +55,6 @@ from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite
from pypaimon.write.table_delete import TableDeleteByRowId
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
__all__ = [
"merge_into",
@@ -618,11 +617,10 @@ def _prepare_commit_messages(
update_snapshot_table = _copy_at_snapshot(
table, base_snapshot.id if base_snapshot is not None else None
)
- updater = TableUpdateByRowId(
- update_snapshot_table,
- commit_user,
- commit_identifier,
- )
+ from pypaimon.write.write_builder import _new_update_by_row_id
+
+ updater = _new_update_by_row_id(
+ update_snapshot_table, commit_user, commit_identifier)
update_msgs = updater.update_columns(
update_table, list(update_cols_union)
)
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index 6b4133242c..61d26111f6 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -5240,9 +5240,6 @@ class DedicatedFormatWriterTest(unittest.TestCase):
wb.new_commit().commit(w.prepare_commit())
w.close()
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
-
table = self.catalog.get_table(table_name)
rb = table.new_read_builder()
rb = rb.with_projection(['name', '_ROW_ID'])
@@ -5253,11 +5250,10 @@ class DedicatedFormatWriterTest(unittest.TestCase):
'_ROW_ID': source.column('_ROW_ID'),
'name': pa.array(['updated', 'updated'], type=pa.string()),
})
- updater = TableUpdateByRowId(
- table, '_test_', BATCH_COMMIT_IDENTIFIER,
- )
+ update_builder = table.new_batch_write_builder()
+ updater = update_builder.new_update().new_update_by_row_id()
msgs = updater.update_columns(update_data, ['name'])
- table.new_batch_write_builder().new_commit().commit(msgs)
+ update_builder.new_commit().commit(msgs)
table = self.catalog.get_table(table_name)
rb = table.new_read_builder()
@@ -5267,8 +5263,6 @@ class DedicatedFormatWriterTest(unittest.TestCase):
def
test_blob_table_partial_update_non_blob_column_with_rolling_files(self):
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
pa_schema = pa.schema([
('id', pa.int32()),
@@ -5322,9 +5316,8 @@ class DedicatedFormatWriterTest(unittest.TestCase):
'_ROW_ID': source.column('_ROW_ID'),
'name': pa.array(['updated'] * source.num_rows, type=pa.string()),
})
- updater = TableUpdateByRowId(
- table, '_test_', BATCH_COMMIT_IDENTIFIER,
- )
+ update_builder = table.new_batch_write_builder()
+ updater = update_builder.new_update().new_update_by_row_id()
msgs = updater.update_columns(update_data, ['name'])
update_normal_files = [
f for msg in msgs for f in msg.new_files
@@ -5335,7 +5328,7 @@ class DedicatedFormatWriterTest(unittest.TestCase):
for file in update_normal_files:
self.assertEqual(file.min_sequence_number, 0)
self.assertEqual(file.max_sequence_number, file.row_count - 1)
- table.new_batch_write_builder().new_commit().commit(msgs)
+ update_builder.new_commit().commit(msgs)
table = self.catalog.get_table(table_name)
rb = table.new_read_builder().with_projection(['id', 'name'])
diff --git a/paimon-python/pypaimon/tests/table_update_test.py
b/paimon-python/pypaimon/tests/table_update_test.py
index 092990ab98..25116b3c3b 100644
--- a/paimon-python/pypaimon/tests/table_update_test.py
+++ b/paimon-python/pypaimon/tests/table_update_test.py
@@ -26,6 +26,7 @@ import pyarrow as pa
import pytest
from pypaimon.read.read_builder import ReadBuilder
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.tests.data_evolution_test_helpers import (
BatchModeMixin,
DataEvolutionTestBase,
@@ -33,6 +34,7 @@ from pypaimon.tests.data_evolution_test_helpers import (
)
from pypaimon.write.table_update import BatchTableUpdate
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.write_builder import BatchWriteBuilder, StreamWriteBuilder
# ======================================================================
@@ -40,6 +42,37 @@ from pypaimon.write.table_update_by_row_id import
TableUpdateByRowId
# ======================================================================
[email protected]("stream, commit_identifier", [
+ (False, BATCH_COMMIT_IDENTIFIER),
+ (True, 123),
+])
+def test_write_builder_creates_row_id_updater_with_commit_identity(
+ stream, commit_identifier):
+ table = mock.Mock()
+ table.options.commit_user_prefix.return_value = None
+ files_info = mock.Mock(
+ snapshot_id=42,
+ first_row_ids=[0],
+ first_row_id_index={},
+ valid_row_id_ranges=[],
+ )
+
+ builder = StreamWriteBuilder(table) if stream else BatchWriteBuilder(table)
+ update = builder.new_update()
+ if stream:
+ updater = update.new_update_by_row_id(
+ commit_identifier, _precomputed_files_info=files_info)
+ else:
+ updater = update.new_update_by_row_id(
+ _precomputed_files_info=files_info)
+
+ assert updater.table is table
+ assert updater.commit_user == update.commit_user == builder.commit_user
+ assert updater.commit_identifier == commit_identifier
+ assert updater.snapshot_id == 42
+ table.new_read_builder.assert_not_called()
+
+
def test_batch_row_id_update_batches_reuse_file_index():
table = mock.MagicMock()
table.field_names = ["value"]
@@ -48,8 +81,8 @@ def test_batch_row_id_update_batches_reuse_file_index():
pa.table({"_ROW_ID": [1], "value": [20]}),
]
- with mock.patch(
- "pypaimon.write.table_update.TableUpdateByRowId") as factory:
+ with mock.patch.object(
+ BatchTableUpdate, "_new_row_id_updater") as factory:
updater = factory.return_value
updater.commit_messages = []
@@ -64,7 +97,7 @@ def test_batch_row_id_update_batches_reuse_file_index():
.update_by_arrow_batches_with_row_id(iter(batches))
)
- factory.assert_called_once()
+ factory.assert_called_once_with(BATCH_COMMIT_IDENTIFIER)
assert updater.update_columns.call_count == 2
assert messages == [
(batches[0], ["value"]),
diff --git a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
index 564d61f3ed..68d26e86e8 100644
--- a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
+++ b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
@@ -675,13 +675,14 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
)
self._compact_all_data_files(table)
+ from pypaimon.write.row_id_file_index import RowIdFileIndex
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
- original_build = TableUpdateByRowId._files_info_from_entries
+ original_build = RowIdFileIndex.from_entries
advanced = [False]
def build_after_concurrent_compaction(
- updater_cls, current_table, snapshot_id, entries):
+ index_cls, current_table, snapshot_id, entries):
if not advanced[0]:
advanced[0] = True
self._write_arrow(table, pa.Table.from_pydict({
@@ -698,8 +699,8 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
'_load_existing_files_info',
side_effect=AssertionError("unexpected snapshot scan"),
), mock.patch.object(
- TableUpdateByRowId,
- '_files_info_from_entries',
+ RowIdFileIndex,
+ 'from_entries',
classmethod(build_after_concurrent_compaction)):
commit = wb.new_commit()
self._apply_commit(commit, messages, commit_identifier)
diff --git a/paimon-python/pypaimon/tests/vector_table_test.py
b/paimon-python/pypaimon/tests/vector_table_test.py
index 96725305fc..574dc747c2 100644
--- a/paimon-python/pypaimon/tests/vector_table_test.py
+++ b/paimon-python/pypaimon/tests/vector_table_test.py
@@ -391,9 +391,6 @@ class VectorTableWriteReadTest(unittest.TestCase):
wb.new_commit().commit(initial_messages)
w.close()
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
-
table = self.catalog.get_table(table_name)
rb = table.new_read_builder()
rb = rb.with_projection(['name', '_ROW_ID'])
@@ -404,11 +401,10 @@ class VectorTableWriteReadTest(unittest.TestCase):
'_ROW_ID': source.column('_ROW_ID'),
'name': pa.array(['updated', 'updated'], type=pa.string()),
})
- updater = TableUpdateByRowId(
- table, '_test_', BATCH_COMMIT_IDENTIFIER,
- )
+ update_builder = table.new_batch_write_builder()
+ updater = update_builder.new_update().new_update_by_row_id()
msgs = updater.update_columns(update_data, ['name'])
- table.new_batch_write_builder().new_commit().commit(msgs)
+ update_builder.new_commit().commit(msgs)
table = self.catalog.get_table(table_name)
rb = table.new_read_builder()
@@ -512,9 +508,6 @@ class VectorTableWriteReadTest(unittest.TestCase):
)
def
test_vector_table_partial_update_non_vector_column_with_rolling_files(self):
- from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
- from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
-
vector_schema = pa.schema([
('id', pa.int32()),
('name', pa.string()),
@@ -567,9 +560,8 @@ class VectorTableWriteReadTest(unittest.TestCase):
'_ROW_ID': source.column('_ROW_ID'),
'name': pa.array(['updated'] * source.num_rows, type=pa.string()),
})
- updater = TableUpdateByRowId(
- table, '_test_', BATCH_COMMIT_IDENTIFIER,
- )
+ update_builder = table.new_batch_write_builder()
+ updater = update_builder.new_update().new_update_by_row_id()
msgs = updater.update_columns(update_data, ['name'])
update_normal_files = [
f for msg in msgs for f in msg.new_files
@@ -579,7 +571,7 @@ class VectorTableWriteReadTest(unittest.TestCase):
for file in update_normal_files:
self.assertEqual(file.min_sequence_number, 0)
self.assertEqual(file.max_sequence_number, file.row_count - 1)
- table.new_batch_write_builder().new_commit().commit(msgs)
+ update_builder.new_commit().commit(msgs)
table = self.catalog.get_table(table_name)
rb = table.new_read_builder().with_projection(['id', 'name'])
diff --git a/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
index bf361d7d62..898fe909df 100644
--- a/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
+++ b/paimon-python/pypaimon/write/commit/row_id_conflict_rewriter.py
@@ -27,7 +27,7 @@ from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
from pypaimon.write.commit_message import CommitMessage
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.row_id_file_index import RowIdFileIndex
@dataclass
@@ -103,7 +103,7 @@ class RowIdConflictRewriter:
new_messages = []
try:
- files_info = TableUpdateByRowId._files_info_from_entries(
+ files_info = RowIdFileIndex.from_entries(
self.table,
latest_snapshot.id,
base_entries,
@@ -126,12 +126,11 @@ class RowIdConflictRewriter:
"overlapping staged files for columns {}.".format(
column_names)
)
- updater = TableUpdateByRowId(
- self.table,
- self.commit_user,
- self.commit_identifier,
- _precomputed_files_info=files_info,
- )
+ from pypaimon.write.write_builder import _new_update_by_row_id
+
+ updater = _new_update_by_row_id(
+ self.table, self.commit_user, self.commit_identifier,
+ _precomputed_files_info=files_info)
try:
new_messages.extend(
updater.update_columns(update_data, column_names)
diff --git a/paimon-python/pypaimon/write/row_id_file_index.py
b/paimon-python/pypaimon/write/row_id_file_index.py
new file mode 100644
index 0000000000..52975ae09c
--- /dev/null
+++ b/paimon-python/pypaimon/write/row_id_file_index.py
@@ -0,0 +1,121 @@
+# 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.
+
+"""Snapshot file index used to route row-id updates."""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Tuple
+
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+from pypaimon.read.scanner.data_evolution_split_generator import (
+ DataEvolutionSplitGenerator,
+)
+from pypaimon.read.split import DataSplit
+from pypaimon.utils.range import Range
+
+
+@dataclass(frozen=True)
+class RowIdFileIndex:
+ """Snapshot files keyed by the first row ID of each data file group."""
+
+ snapshot_id: int
+ first_row_ids: List[int]
+ first_row_id_index: Dict[int, Tuple[DataSplit, List[DataFileMeta]]] = (
+ field(default_factory=dict)
+ )
+ valid_row_id_ranges: List[Range] = field(default_factory=list)
+
+ @classmethod
+ def from_table(cls, table) -> "RowIdFileIndex":
+ """Plan the table's write snapshot and index its splits."""
+ scan = table.new_read_builder().new_scan()
+ plan = scan.plan_for_write()
+ snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
+ return cls.from_splits(snapshot_id, plan.splits())
+
+ @classmethod
+ def from_entries(
+ cls, table, snapshot_id: int, entries: List[ManifestEntry]
+ ) -> "RowIdFileIndex":
+ """Index an already resolved snapshot entry set."""
+ splits = DataEvolutionSplitGenerator(
+ table,
+ table.options.source_split_target_size(),
+ table.options.source_split_open_file_cost(),
+ ).create_splits(entries)
+ return cls.from_splits(snapshot_id, splits)
+
+ @classmethod
+ def from_splits(
+ cls, snapshot_id: int, splits: List[DataSplit]
+ ) -> "RowIdFileIndex":
+ index: Dict[int, Tuple[DataSplit, List[DataFileMeta]]] = {}
+ row_id_ranges: List[Range] = []
+ for split in splits:
+ files_with_row_id = [
+ file for file in split.files if file.first_row_id is not None
+ ]
+ data_files = [
+ file for file in files_with_row_id
+ if not DataFileMeta.is_blob_file(file.file_name)
+ ]
+ for file in split.files:
+ if (
+ file.first_row_id is None
+ or DataFileMeta.is_blob_file(file.file_name)
+ ):
+ continue
+ row_id_ranges.append(file.row_id_range())
+ for file in data_files:
+ target_files = [
+ target_file
+ for target_file in files_with_row_id
+ if cls._overlaps(
+ file.row_id_range(), target_file.row_id_range()
+ )
+ ]
+
+ entry = index.get(file.first_row_id)
+ if entry is None:
+ index[file.first_row_id] = (split, target_files)
+ else:
+ existing_files = entry[1]
+ existing_names = {
+ existing.file_name for existing in existing_files
+ }
+ existing_files.extend(
+ target_file
+ for target_file in target_files
+ if target_file.file_name not in existing_names
+ )
+
+ if row_id_ranges:
+ merged = Range.sort_and_merge_overlap(row_id_ranges, True, True)
+ else:
+ merged = []
+
+ return cls(
+ snapshot_id=snapshot_id,
+ first_row_ids=sorted(index.keys()),
+ first_row_id_index=index,
+ valid_row_id_ranges=merged,
+ )
+
+ @staticmethod
+ def _overlaps(left: Range, right: Range) -> bool:
+ return left.from_ <= right.to and right.from_ <= left.to
diff --git a/paimon-python/pypaimon/write/table_update.py
b/paimon-python/pypaimon/write/table_update.py
index 922b1bc8c3..494127d791 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -46,6 +46,7 @@ from pypaimon.snapshot.time_travel_util import SCAN_KEYS,
TimeTravelUtil
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.file_store_commit import _abort_commit_messages
+from pypaimon.write.row_id_file_index import RowIdFileIndex
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
@@ -125,6 +126,16 @@ class TableUpdate:
self.update_cols = None
self.projection = None
+ def _new_row_id_updater(
+ self, commit_identifier: int, _precomputed_files_info=None
+ ) -> TableUpdateByRowId:
+ return TableUpdateByRowId(
+ self.table,
+ self.commit_user,
+ commit_identifier,
+ _precomputed_files_info=_precomputed_files_info,
+ )
+
def with_update_type(self, update_cols: List[str]):
update_cols = list(dict.fromkeys(update_cols))
for col in update_cols:
@@ -163,9 +174,8 @@ class TableUpdate:
cols = self.update_cols if self.update_cols is not None else [
c for c in table.column_names if c != SpecialFields.ROW_ID.name
]
- return TableUpdateByRowId(
- self.table, self.commit_user, commit_identifier,
- ).update_columns(table, cols)
+ return self._new_row_id_updater(commit_identifier).update_columns(
+ table, cols)
def _update_by_arrow_batches_with_row_id(
self, tables: Iterable[pa.Table], commit_identifier: int
@@ -178,8 +188,7 @@ class TableUpdate:
if c != SpecialFields.ROW_ID.name
]
if updater is None:
- updater = TableUpdateByRowId(
- self.table, self.commit_user, commit_identifier)
+ updater = self._new_row_id_updater(commit_identifier)
updater.update_columns(table, cols)
return [] if updater is None else updater.commit_messages
except Exception:
@@ -283,14 +292,11 @@ class TableUpdate:
plan = read_builder.new_scan().plan_for_write()
splits = plan.splits()
snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
- files_info = TableUpdateByRowId._files_info_from_splits(
+ files_info = RowIdFileIndex.from_splits(
snapshot_id, splits
)
table_read = read_builder.new_read()
- updater = TableUpdateByRowId(
- self.table, self.commit_user, commit_identifier,
- _precomputed_files_info=files_info,
- )
+ updater = self._new_row_id_updater(commit_identifier, files_info)
try:
if has_array:
matched = table_read.to_arrow(splits)
@@ -641,6 +647,12 @@ class BatchTableUpdate(TableUpdate):
"""Batch-mode table update; commit messages always use
:data:`BATCH_COMMIT_IDENTIFIER`."""
+ def new_update_by_row_id(
+ self, _precomputed_files_info=None) -> TableUpdateByRowId:
+ """Create a row-id updater using this batch update's commit user."""
+ return self._new_row_id_updater(
+ BATCH_COMMIT_IDENTIFIER, _precomputed_files_info)
+
def update_by_arrow_with_row_id(self, table: pa.Table) ->
List[CommitMessage]:
"""Apply column updates keyed by ``_ROW_ID`` to existing rows."""
return self._update_by_arrow_with_row_id(table,
BATCH_COMMIT_IDENTIFIER)
@@ -720,6 +732,13 @@ class StreamTableUpdate(TableUpdate):
"""Stream-mode table update; the same instance may drive many rounds,
each tagged with its own ``commit_identifier``."""
+ def new_update_by_row_id(
+ self, commit_identifier: int,
+ _precomputed_files_info=None) -> TableUpdateByRowId:
+ """Create a row-id updater for a stream commit identifier."""
+ return self._new_row_id_updater(
+ commit_identifier, _precomputed_files_info)
+
def update_by_arrow_with_row_id(
self, table: pa.Table, commit_identifier: int
) -> List[CommitMessage]:
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index 9e6275fd4a..a8af846c9c 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -17,7 +17,6 @@
import bisect
import uuid
-from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Set, Tuple
import numpy as np
@@ -26,11 +25,7 @@ import pyarrow.compute as pc
from pypaimon.common.options.core_options import ChangelogProducer, CoreOptions
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
-from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.manifest.schema.simple_stats import SimpleStats
-from pypaimon.read.scanner.data_evolution_split_generator import (
- DataEvolutionSplitGenerator,
-)
from pypaimon.read.split import DataSplit
from pypaimon.read.table_read import TableRead
from pypaimon.schema.data_types import (
@@ -43,9 +38,9 @@ from pypaimon.schema.data_types import (
from pypaimon.table.row.blob import Blob
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
-from pypaimon.utils.range import Range
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.file_store_write import FileStoreWrite
+from pypaimon.write.row_id_file_index import RowIdFileIndex
from pypaimon.write.row_utils import (
require_columns,
row_to_named_values,
@@ -61,21 +56,6 @@ _ARROW_MAJOR = int(pa.__version__.split('.')[0])
_DEFAULT_PARQUET_BLOCK_SIZE = 128 * 1024 * 1024
-@dataclass(frozen=True)
-class _FilesInfo:
- """Snapshot view of target data files keyed by first_row_id.
-
- Built once per merge by the driver and broadcast to workers so each task
- avoids re-scanning the manifest.
- """
- snapshot_id: int
- first_row_ids: List[int]
- first_row_id_index: Dict[int, Tuple[DataSplit, List[DataFileMeta]]] = (
- field(default_factory=dict)
- )
- valid_row_id_ranges: List[Range] = field(default_factory=list)
-
-
class _RowIdUpdateFileWriter:
"""Write one plain-Parquet update file for a row-id file group."""
@@ -223,7 +203,7 @@ class TableUpdateByRowId:
def __init__(
self, table, commit_user: str, commit_identifier: int,
- _precomputed_files_info: Optional[_FilesInfo] = None,
+ _precomputed_files_info: Optional[RowIdFileIndex] = None,
):
from pypaimon.table.file_store_table import FileStoreTable
@@ -240,101 +220,24 @@ class TableUpdateByRowId:
self.commit_messages: List[CommitMessage] = []
self._updated_first_row_ids_by_column: Dict[str, Set[int]] = {}
- def _snapshot_files_info(self) -> _FilesInfo:
+ def _snapshot_files_info(self) -> RowIdFileIndex:
"""Return the already loaded snapshot file index for broadcast."""
- return _FilesInfo(
+ return RowIdFileIndex(
snapshot_id=self.snapshot_id,
first_row_ids=self.first_row_ids,
first_row_id_index=self._first_row_id_index,
valid_row_id_ranges=self.valid_row_id_ranges,
)
- def _load_existing_files_info(self) -> _FilesInfo:
+ def _load_existing_files_info(self) -> RowIdFileIndex:
"""Scan the latest snapshot once and index files by ``first_row_id``.
- Returns a :class:`_FilesInfo` whose ``first_row_id_index`` maps each
+ Returns a :class:`RowIdFileIndex` whose ``first_row_id_index`` maps
each
``first_row_id`` to the owning split and the list of files with that
id (a single id may belong to multiple files when data evolution has
split a logical row range).
"""
- scan = self.table.new_read_builder().new_scan()
- plan = scan.plan_for_write()
- snapshot_id = plan.snapshot_id if plan.snapshot_id is not None else -1
- return self._files_info_from_splits(snapshot_id, plan.splits())
-
- @classmethod
- def _files_info_from_entries(
- cls,
- table,
- snapshot_id: int,
- entries: List[ManifestEntry],
- ) -> _FilesInfo:
- """Build a file index from an already resolved snapshot entry set."""
- splits = DataEvolutionSplitGenerator(
- table,
- table.options.source_split_target_size(),
- table.options.source_split_open_file_cost(),
- ).create_splits(entries)
- return cls._files_info_from_splits(snapshot_id, splits)
-
- @classmethod
- def _files_info_from_splits(
- cls, snapshot_id: int, splits: List[DataSplit]) -> _FilesInfo:
- index: Dict[int, Tuple[DataSplit, List[DataFileMeta]]] = {}
- row_id_ranges: List[Range] = []
- for split in splits:
- files_with_row_id = [
- file for file in split.files if file.first_row_id is not None
- ]
- data_files = [
- file for file in files_with_row_id
- if not DataFileMeta.is_blob_file(file.file_name)
- ]
- for file in split.files:
- if (
- file.first_row_id is None
- or DataFileMeta.is_blob_file(file.file_name)
- ):
- continue
- row_id_ranges.append(file.row_id_range())
- for file in data_files:
- target_files = [
- target_file
- for target_file in files_with_row_id
- if cls._overlaps(
- file.row_id_range(), target_file.row_id_range()
- )
- ]
-
- entry = index.get(file.first_row_id)
- if entry is None:
- index[file.first_row_id] = (split, target_files)
- else:
- existing_files = entry[1]
- existing_names = {
- existing.file_name for existing in existing_files
- }
- existing_files.extend(
- target_file
- for target_file in target_files
- if target_file.file_name not in existing_names
- )
-
- if row_id_ranges:
- merged = Range.sort_and_merge_overlap(row_id_ranges, True, True)
- else:
- merged = []
-
- return _FilesInfo(
- snapshot_id=snapshot_id,
- first_row_ids=sorted(index.keys()),
- first_row_id_index=index,
- valid_row_id_ranges=merged,
- )
-
- @staticmethod
- def _overlaps(left: Range, right: Range) -> bool:
- return left.from_ <= right.to and right.from_ <= left.to
+ return RowIdFileIndex.from_table(self.table)
def update_columns(self, data: pa.Table, column_names: List[str]) ->
List[CommitMessage]:
"""
diff --git a/paimon-python/pypaimon/write/table_upsert_by_key.py
b/paimon-python/pypaimon/write/table_upsert_by_key.py
index 4981b8187f..aebbdf6176 100644
--- a/paimon-python/pypaimon/write/table_upsert_by_key.py
+++ b/paimon-python/pypaimon/write/table_upsert_by_key.py
@@ -26,7 +26,6 @@ from pypaimon.table.row.internal_row import InternalRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.row_utils import require_columns, row_to_named_values
-from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_write import StreamTableWrite
# Composite key is represented as a tuple of values
@@ -54,6 +53,12 @@ class TableUpsertByKey:
self.commit_user = commit_user
self.commit_identifier = commit_identifier
+ def _new_row_id_updater(self):
+ from pypaimon.write.write_builder import _new_update_by_row_id
+
+ return _new_update_by_row_id(
+ self.table, self.commit_user, self.commit_identifier)
+
def upsert(self, data: pa.Table, upsert_keys: List[str],
update_cols: Optional[List[str]] = None) -> List[CommitMessage]:
"""
@@ -200,9 +205,7 @@ class TableUpsertByKey:
)
for _, values_by_name in matched_items:
require_columns(values_by_name, cols_to_update,
"upsert_by_key")
- commit_messages.extend(TableUpdateByRowId(
- self.table, self.commit_user, self.commit_identifier,
- ).update_rows_columns(
+
commit_messages.extend(self._new_row_id_updater().update_rows_columns(
[row for row, _ in matched_items],
matched_row_ids,
cols_to_update,
@@ -551,9 +554,8 @@ class TableUpsertByKey:
)
cols_to_update = list(update_cols) if update_cols else
list(self.table.field_names)
- return TableUpdateByRowId(
- self.table, self.commit_user, self.commit_identifier,
- ).update_columns(update_data, cols_to_update)
+ return self._new_row_id_updater().update_columns(
+ update_data, cols_to_update)
def _do_appends(
self,
diff --git a/paimon-python/pypaimon/write/write_builder.py
b/paimon-python/pypaimon/write/write_builder.py
index 0f79308ab9..bfe76659f9 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -46,6 +46,11 @@ class WriteBuilder(ABC):
def new_commit(self) -> TableCommit:
"""Returns a table commit."""
+ def _with_commit_user(self, commit_user: str):
+ """Reuse the identity of an existing write or commit operation."""
+ self.commit_user = commit_user
+ return self
+
def _create_commit_user(self):
commit_user_prefix = self.table.options.commit_user_prefix()
if commit_user_prefix is not None:
@@ -111,3 +116,21 @@ class StreamWriteBuilder(WriteBuilder):
def new_commit(self) -> StreamTableCommit:
commit = StreamTableCommit(self.table, self.commit_user)
return commit
+
+
+def _new_update_by_row_id(
+ table, commit_user: str, commit_identifier: int,
+ _precomputed_files_info=None):
+ """Build an internal updater with an operation's existing commit
identity."""
+ from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+
+ if commit_identifier == BATCH_COMMIT_IDENTIFIER:
+ return (table.new_batch_write_builder()
+ ._with_commit_user(commit_user)
+ .new_update()
+ .new_update_by_row_id(_precomputed_files_info))
+ return (table.new_stream_write_builder()
+ ._with_commit_user(commit_user)
+ .new_update()
+ .new_update_by_row_id(
+ commit_identifier, _precomputed_files_info))