blob: 588825c36fafd6d0f4df62ff1fe5adc41fa02fd8 [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.
import os
import random
import string
import threading
import unittest
from unittest import mock
import pyarrow as pa
import pytest
from pypaimon.tests.data_evolution_test_helpers import (
BatchModeMixin,
DataEvolutionTestBase,
StreamModeMixin,
)
# ======================================================================
# Shared base for batch & stream table-update tests
# ======================================================================
class _TableUpdateTestBase(DataEvolutionTestBase):
"""Shared tests for ``TableUpdate.update_by_arrow_with_row_id``.
Concrete subclasses must inherit from :class:`unittest.TestCase` AND one
of :class:`_BatchModeMixin` / :class:`_StreamModeMixin`, which add the
operation-specific primitive ``_apply_update(tu, data, cid)`` on top of
the framework primitives provided by
:class:`DataEvolutionTestBase`/:class:`BatchModeMixin`/:class:`StreamModeMixin`.
"""
# ------------------------------------------------------------------
# Operation-specific primitive (overridden by mixins)
# ------------------------------------------------------------------
def _apply_update(self, table_update, data, cid):
raise NotImplementedError
def _apply_update_by_predicate(
self, table_update, predicate, assignments, cid):
raise NotImplementedError
def _apply_delete_by_predicate(self, table_update, predicate, cid):
raise NotImplementedError
def _apply_delete_by_row_id(self, table_update, row_ids, cid):
raise NotImplementedError
# ------------------------------------------------------------------
# Helpers built on the primitives
# ------------------------------------------------------------------
def _create_seeded_table(self, partition_keys=None, options=None):
"""Create the canonical 5-row / 2-file table used by most tests.
Layout (row_id → row):
0: (1, Alice, 25, NYC)
1: (2, Bob, 30, LA)
2: (3, Charlie, 35, Chicago)
3: (4, David, 40, Houston)
4: (5, Eve, 45, Phoenix)
"""
table = self._create_table(
partition_keys=partition_keys,
options=options,
)
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2],
'name': ['Alice', 'Bob'],
'age': [25, 30],
'city': ['NYC', 'LA'],
}, schema=self.pa_schema))
self._write_arrow(table, pa.Table.from_pydict({
'id': [3, 4, 5],
'name': ['Charlie', 'David', 'Eve'],
'age': [35, 40, 45],
'city': ['Chicago', 'Houston', 'Phoenix'],
}, schema=self.pa_schema))
return table
def _create_global_indexed_table_for_predicate_update(self, extra_options=None):
options = dict(self.table_options)
options.update({
'global-index.enabled': 'true',
'bucket': '-1',
'file.format': 'parquet',
})
options.update(extra_options or {})
table = self._create_table(options=options)
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2],
'name': ['old', 'indexed'],
'age': [10, 15],
'city': ['NYC', 'LA'],
}, schema=self.pa_schema))
self.assertEqual(
1,
table.create_global_index(
'name',
options={'sorted-index.records-per-range': '1000'},
),
)
self._write_arrow(table, pa.Table.from_pydict({
'id': [3, 4],
'name': ['new', 'other'],
'age': [20, 30],
'city': ['LA', 'SF'],
}, schema=self.pa_schema))
return table
def _do_update(self, table, data, columns):
"""End-to-end ``update_by_arrow_with_row_id`` + commit. Returns the
commit messages so callers can inspect produced files."""
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(columns)
cid = self._next_commit_id()
msgs = self._apply_update(tu, data, cid)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
return msgs
def _do_update_by_predicate(self, table, predicate, assignments):
wb = self._make_write_builder(table)
tu = wb.new_update()
cid = self._next_commit_id()
msgs = self._apply_update_by_predicate(
tu,
predicate,
assignments,
cid,
)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
return msgs
def _do_delete_by_predicate(self, table, predicate):
wb = self._make_write_builder(table)
tu = wb.new_update()
cid = self._next_commit_id()
msgs = self._apply_delete_by_predicate(tu, predicate, cid)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
return msgs
def _do_delete_by_row_id(self, table, row_ids):
wb = self._make_write_builder(table)
tu = wb.new_update()
cid = self._next_commit_id()
msgs = self._apply_delete_by_row_id(tu, row_ids, cid)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
return msgs
def _create_seeded_deletion_vector_table(self, partition_keys=None):
options = dict(self.table_options)
options['deletion-vectors.enabled'] = 'true'
table = self._create_seeded_table(
partition_keys=partition_keys,
options=options,
)
return table
# ==================================================================
# Shared tests (run under both batch and stream modes)
# ==================================================================
def test_update_existing_column(self):
"""Update a single column across both files (row_ids unordered)."""
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 0, 2, 3, 4],
'age': [31, 26, 36, 39, 42],
}), ['age'])
self.assertEqual(
[26, 31, 36, 39, 42],
self._read_all(table)['age'].to_pylist(),
)
@pytest.mark.python_plan
def test_update_by_predicate(self):
table = self._create_seeded_table()
wb = self._make_write_builder(table)
tu = wb.new_update()
pb = tu.new_predicate_builder()
predicate = pb.greater_or_equal('age', 35)
cid = self._next_commit_id()
msgs = self._apply_update_by_predicate(
tu,
predicate,
{'age': 99, 'city': 'Updated'},
cid,
)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
result = self._read_all(table)
self.assertEqual(
[25, 30, 99, 99, 99],
result['age'].to_pylist(),
)
self.assertEqual(
['NYC', 'LA', 'Updated', 'Updated', 'Updated'],
result['city'].to_pylist(),
)
self.assertEqual(
['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
result['name'].to_pylist(),
)
def test_update_by_predicate_no_match_is_noop(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
msgs = self._do_update_by_predicate(
table,
pb.greater_than('age', 100),
{'age': 1},
)
self.assertEqual([], msgs)
self.assertEqual(
[25, 30, 35, 40, 45],
self._read_all(table)['age'].to_pylist(),
)
def test_update_by_predicate_accepts_array_chunked_and_scalar_values(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
self._do_update_by_predicate(
table,
pb.greater_or_equal('age', 35),
{
'age': pa.array([101, 102, 103], type=pa.int64()),
'city': pa.chunked_array([
pa.array(['Chicago_v2']),
pa.array(['Houston_v2', 'Phoenix_v2']),
]),
'name': pa.scalar('patched'),
},
)
result = self._read_all(table)
rows = {
row_id: (name, age, city)
for row_id, name, age, city in zip(
result['id'].to_pylist(),
result['name'].to_pylist(),
result['age'].to_pylist(),
result['city'].to_pylist(),
)
}
self.assertEqual(
{
1: ('Alice', 25, 'NYC'),
2: ('Bob', 30, 'LA'),
3: ('patched', 101, 'Chicago_v2'),
4: ('patched', 102, 'Houston_v2'),
5: ('patched', 103, 'Phoenix_v2'),
},
rows,
)
def test_update_by_predicate_updates_all_rows_when_predicate_is_none(self):
table = self._create_seeded_table()
self._do_update_by_predicate(
table,
None,
{'age': pa.scalar(7, type=pa.int64()), 'city': None},
)
result = self._read_all(table)
self.assertEqual([7, 7, 7, 7, 7], result['age'].to_pylist())
self.assertEqual([None, None, None, None, None],
result['city'].to_pylist())
self.assertEqual(
['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
result['name'].to_pylist(),
)
def test_update_by_predicate_rejects_assignment_array_length_mismatch(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises(ValueError) as ctx:
self._do_update_by_predicate(
table,
pb.greater_or_equal('age', 35),
{'age': pa.array([1, 2], type=pa.int32())},
)
self.assertIn('Assignment array length', str(ctx.exception))
self.assertEqual(
[25, 30, 35, 40, 45],
self._read_all(table)['age'].to_pylist(),
)
def test_update_by_predicate_rejects_uncastable_assignment(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises((pa.ArrowInvalid, pa.ArrowTypeError, ValueError)):
self._do_update_by_predicate(
table,
pb.equal('id', 1),
{'age': 'not-an-int'},
)
self.assertEqual(
[25, 30, 35, 40, 45],
self._read_all(table)['age'].to_pylist(),
)
def test_update_by_predicate_with_global_index_updates_unindexed_rows(self):
table = self._create_global_indexed_table_for_predicate_update({
'scalar-index.search-mode': 'fast',
})
pb = table.new_read_builder().new_predicate_builder()
self._do_update_by_predicate(
table,
pb.equal('name', 'new'),
{'age': 21},
)
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 10, 2: 15, 3: 21, 4: 30}, ages_by_id)
def test_delete_by_predicate_with_global_index_deletes_unindexed_rows(self):
table = self._create_global_indexed_table_for_predicate_update({
'deletion-vectors.enabled': 'true',
'scalar-index.search-mode': 'fast',
})
pb = table.new_read_builder().new_predicate_builder()
self._do_delete_by_predicate(table, pb.equal('name', 'new'))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 2, 4], result['id'].to_pylist())
def test_update_by_predicate_with_global_index_falls_back_to_full_scan(self):
table = self._create_global_indexed_table_for_predicate_update()
pb = table.new_read_builder().new_predicate_builder()
self._do_update_by_predicate(
table,
pb.equal('city', 'LA'),
{'age': 88},
)
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 10, 2: 88, 3: 88, 4: 30}, ages_by_id)
def test_update_by_predicate_with_global_index_handles_compound_predicate(self):
table = self._create_global_indexed_table_for_predicate_update()
pb = table.new_read_builder().new_predicate_builder()
predicate = pb.or_predicates([
pb.equal('name', 'old'),
pb.equal('city', 'SF'),
])
self._do_update_by_predicate(table, predicate, {'age': 77})
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 77, 2: 15, 3: 20, 4: 77}, ages_by_id)
def test_update_by_predicate_resolves_time_travel_scan_snapshot(self):
table = self._create_table()
self._write_arrow(table, pa.Table.from_pydict({
'id': [1],
'name': ['old'],
'age': [10],
'city': ['NYC'],
}, schema=self.pa_schema))
table.create_tag('before_new')
self._write_arrow(table, pa.Table.from_pydict({
'id': [2],
'name': ['new'],
'age': [20],
'city': ['LA'],
}, schema=self.pa_schema))
travel_table = table.copy({'scan.tag-name': 'before_new'})
pb = travel_table.new_read_builder().new_predicate_builder()
msgs = self._do_update_by_predicate(
travel_table,
pb.equal('name', 'new'),
{'age': 21},
)
self.assertEqual([], msgs)
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 10, 2: 20}, ages_by_id)
def test_update_by_predicate_resolves_scan_snapshot_id(self):
table = self._create_table()
self._write_arrow(table, pa.Table.from_pydict({
'id': [1],
'name': ['old'],
'age': [10],
'city': ['NYC'],
}, schema=self.pa_schema))
snapshot = table.snapshot_manager().get_latest_snapshot()
self._write_arrow(table, pa.Table.from_pydict({
'id': [2],
'name': ['new'],
'age': [20],
'city': ['LA'],
}, schema=self.pa_schema))
travel_table = table.copy({'scan.snapshot-id': str(snapshot.id)})
pb = travel_table.new_read_builder().new_predicate_builder()
msgs = self._do_update_by_predicate(
travel_table,
pb.equal('name', 'new'),
{'age': 21},
)
self.assertEqual([], msgs)
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 10, 2: 20}, ages_by_id)
def test_update_by_predicate_resets_explicit_scan_mode_after_travel(self):
table = self._create_table()
self._write_arrow(table, pa.Table.from_pydict({
'id': [1],
'name': ['old'],
'age': [10],
'city': ['NYC'],
}, schema=self.pa_schema))
snapshot = table.snapshot_manager().get_latest_snapshot()
self._write_arrow(table, pa.Table.from_pydict({
'id': [2],
'name': ['new'],
'age': [20],
'city': ['LA'],
}, schema=self.pa_schema))
travel_table = table.copy({
'scan.mode': 'from-timestamp',
'scan.timestamp-millis': str(snapshot.time_millis),
})
pb = travel_table.new_read_builder().new_predicate_builder()
msgs = self._do_update_by_predicate(
travel_table,
pb.equal('name', 'new'),
{'age': 21},
)
self.assertEqual([], msgs)
result = self._read_all(table)
ages_by_id = dict(zip(
result['id'].to_pylist(),
result['age'].to_pylist(),
))
self.assertEqual({1: 10, 2: 20}, ages_by_id)
def test_update_by_predicate_rejects_empty_assignments(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises(ValueError) as ctx:
self._do_update_by_predicate(
table,
pb.equal('id', 1),
{},
)
self.assertIn('assignments must not be empty', str(ctx.exception))
def test_update_by_predicate_rejects_unknown_column(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises(ValueError) as ctx:
self._do_update_by_predicate(
table,
pb.equal('id', 1),
{'unknown': 1},
)
self.assertIn('Column unknown is not in table schema',
str(ctx.exception))
def test_update_by_predicate_rejects_partition_column(self):
table = self._create_table(partition_keys=['city'])
self._write_arrow(table, pa.Table.from_pydict({
'id': [1],
'name': ['Alice'],
'age': [25],
'city': ['NYC'],
}, schema=self.pa_schema))
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises(ValueError) as ctx:
self._do_update_by_predicate(
table,
pb.equal('id', 1),
{'city': 'LA'},
)
self.assertIn('partition column', str(ctx.exception))
def test_delete_by_predicate(self):
table = self._create_seeded_deletion_vector_table()
pb = table.new_read_builder().new_predicate_builder()
msgs = self._do_delete_by_predicate(
table,
pb.greater_or_equal('age', 35),
)
self.assertEqual(1, sum(len(m.index_adds) for m in msgs))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 2], result['id'].to_pylist())
self.assertEqual(['Alice', 'Bob'], result['name'].to_pylist())
def test_delete_by_predicate_requires_deletion_vectors(self):
table = self._create_seeded_table()
pb = table.new_read_builder().new_predicate_builder()
with self.assertRaises(ValueError) as ctx:
self._do_delete_by_predicate(table, pb.equal('id', 1))
self.assertIn('deletion-vectors.enabled', str(ctx.exception))
def test_delete_by_row_id(self):
table = self._create_seeded_deletion_vector_table()
msgs = self._do_delete_by_row_id(table, [0, 2, 4])
self.assertEqual(1, sum(len(m.index_adds) for m in msgs))
result = self._read_all(table).sort_by('id')
self.assertEqual([2, 4], result['id'].to_pylist())
self.assertEqual(['Bob', 'David'], result['name'].to_pylist())
def test_delete_by_row_id_requires_deletion_vectors(self):
table = self._create_seeded_table()
with self.assertRaises(ValueError) as ctx:
self._do_delete_by_row_id(table, [0])
self.assertIn('deletion-vectors.enabled', str(ctx.exception))
def test_concurrent_row_level_deletes_conflict_on_same_dv_file(self):
table = self._create_seeded_deletion_vector_table()
pb = table.new_read_builder().new_predicate_builder()
first_wb = self._make_write_builder(table)
first_update = first_wb.new_update()
first_cid = self._next_commit_id()
first_msgs = self._apply_delete_by_predicate(
first_update,
pb.equal('id', 1),
first_cid,
)
self._do_delete_by_predicate(table, pb.equal('id', 2))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 3, 4, 5], result['id'].to_pylist())
first_commit = first_wb.new_commit()
with self.assertRaises(RuntimeError) as ctx:
self._apply_commit(first_commit, first_msgs, first_cid)
first_commit.close()
self.assertIn('Deletion vector index conflict', str(ctx.exception))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 3, 4, 5], result['id'].to_pylist())
def test_row_level_delete_conflicts_when_target_file_removed(self):
table = self._create_seeded_deletion_vector_table(partition_keys=['city'])
pb = table.new_read_builder().new_predicate_builder()
first_wb = self._make_write_builder(table)
first_update = first_wb.new_update()
first_cid = self._next_commit_id()
first_msgs = self._apply_delete_by_predicate(
first_update,
pb.equal('id', 2),
first_cid,
)
overwrite_wb = table.new_batch_write_builder().overwrite({'city': 'LA'})
overwrite_write = overwrite_wb.new_write()
overwrite_commit = overwrite_wb.new_commit()
overwrite_write.write_arrow(pa.Table.from_pydict({
'id': [6],
'name': ['Frank'],
'age': [28],
'city': ['LA'],
}, schema=self.pa_schema))
overwrite_commit.commit(overwrite_write.prepare_commit())
overwrite_write.close()
overwrite_commit.close()
first_commit = first_wb.new_commit()
with self.assertRaises(RuntimeError) as ctx:
self._apply_commit(first_commit, first_msgs, first_cid)
first_commit.close()
self.assertIn('Deletion vector index conflict', str(ctx.exception))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 3, 4, 5, 6], result['id'].to_pylist())
def test_delete_by_partition_predicate_drops_partition_without_dv(self):
table = self._create_seeded_table(partition_keys=['city'])
pb = table.new_read_builder().new_predicate_builder()
msgs = self._do_delete_by_predicate(table, pb.equal('city', 'LA'))
self.assertGreater(sum(len(m.deleted_files) for m in msgs), 0)
self.assertEqual(0, sum(len(m.index_adds) for m in msgs))
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 3, 4, 5], result['id'].to_pylist())
self.assertEqual(
['NYC', 'Chicago', 'Houston', 'Phoenix'],
result['city'].to_pylist(),
)
def test_delete_by_partition_predicate_deletes_matching_indexes(self):
table = self._create_seeded_deletion_vector_table(partition_keys=['city'])
pb = table.new_read_builder().new_predicate_builder()
self._do_delete_by_predicate(table, pb.equal('id', 2))
msgs = self._do_delete_by_predicate(table, pb.equal('city', 'LA'))
self.assertGreater(sum(len(m.deleted_files) for m in msgs), 0)
self.assertGreater(sum(len(m.index_deletes) for m in msgs), 0)
result = self._read_all(table).sort_by('id')
self.assertEqual([1, 3, 4, 5], result['id'].to_pylist())
self.assertEqual(
['NYC', 'Chicago', 'Houston', 'Phoenix'],
result['city'].to_pylist(),
)
def test_delete_by_partition_predicate_conflicts_when_partition_changes(self):
table = self._create_seeded_table(partition_keys=['city'])
wb = self._make_write_builder(table)
tu = wb.new_update()
pb = tu.new_predicate_builder()
cid = self._next_commit_id()
msgs = self._apply_delete_by_predicate(tu, pb.equal('city', 'LA'), cid)
self._write_arrow(table, pa.Table.from_pydict({
'id': [6],
'name': ['Frank'],
'age': [28],
'city': ['LA'],
}, schema=self.pa_schema))
tc = wb.new_commit()
with self.assertRaises(RuntimeError) as ctx:
self._apply_commit(tc, msgs, cid)
tc.close()
self.assertIn('Overwrite conflict', str(ctx.exception))
def test_delete_by_mixed_partition_predicate_still_requires_dv(self):
table = self._create_seeded_table(partition_keys=['city'])
pb = table.new_read_builder().new_predicate_builder()
predicate = pb.and_predicates([
pb.equal('city', 'LA'),
pb.equal('age', 30),
])
with self.assertRaises(ValueError) as ctx:
self._do_delete_by_predicate(table, predicate)
self.assertIn('deletion-vectors.enabled', str(ctx.exception))
def test_update_multiple_columns(self):
"""Update ``age`` + ``city`` together; other columns are untouched."""
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 0, 2, 3, 4],
'age': [31, 26, 36, 39, 42],
'city': ['Los Angeles', 'New York', 'Chicago', 'Phoenix', 'Houston'],
}), ['age', 'city'])
result = self._read_all(table)
self.assertEqual([26, 31, 36, 39, 42], result['age'].to_pylist())
self.assertEqual(
['New York', 'Los Angeles', 'Chicago', 'Phoenix', 'Houston'],
result['city'].to_pylist(),
)
def test_update_columns_fall_back_to_data_when_unset(self):
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [0, 1, 2, 3, 4],
'id': [1, 2, 3, 4, 5],
'name': ['A', 'B', 'C', 'D', 'E'],
'age': [1, 2, 3, 4, 5],
'city': ['c0', 'c1', 'c2', 'c3', 'c4'],
}), ['id', 'name', 'age', 'city'])
result = self._read_all(table)
self.assertEqual(['A', 'B', 'C', 'D', 'E'], result['name'].to_pylist())
self.assertEqual([1, 2, 3, 4, 5], result['age'].to_pylist())
self.assertEqual(['c0', 'c1', 'c2', 'c3', 'c4'], result['city'].to_pylist())
wb = self._make_write_builder(table)
tu = wb.new_update()
cid = self._next_commit_id()
msgs = self._apply_update(tu, pa.Table.from_pydict({
'_ROW_ID': [0, 1],
'age': [99, 98],
}), cid)
tc = wb.new_commit()
self._apply_commit(tc, msgs, cid)
tc.close()
result = self._read_all(table)
self.assertEqual([99, 98, 3, 4, 5], result['age'].to_pylist())
self.assertEqual(['A', 'B', 'C', 'D', 'E'], result['name'].to_pylist())
def test_update_with_only_row_id_raises(self):
table = self._create_seeded_table()
wb = self._make_write_builder(table)
tu = wb.new_update()
cid = self._next_commit_id()
with self.assertRaises(ValueError):
self._apply_update(tu, pa.Table.from_pydict({
'_ROW_ID': [0, 1],
}), cid)
@pytest.mark.python_plan
def test_partitioned_table_update(self):
"""Updates work on a partitioned table the same as a flat one."""
table = self._create_table(partition_keys=['city'])
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
'age': [25, 30, 35, 40, 45],
'city': ['NYC', 'NYC', 'LA', 'LA', 'Chicago'],
}, schema=self.pa_schema))
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 0, 2, 3, 4],
'age': [31, 26, 36, 41, 46],
}), ['age'])
self.assertEqual(
[26, 31, 36, 41, 46],
self._read_all(table)['age'].to_pylist(),
)
def test_update_preserves_other_columns(self):
"""Updating ``age`` leaves all other columns untouched."""
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1],
'age': [999],
}), ['age'])
result = self._read_all(table)
self.assertEqual([25, 999, 35, 40, 45], result['age'].to_pylist())
self.assertEqual(
['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
result['name'].to_pylist(),
)
self.assertEqual(
['NYC', 'LA', 'Chicago', 'Houston', 'Phoenix'],
result['city'].to_pylist(),
)
def test_partial_row_updates(self):
"""All partial-row patterns share one parameterised test."""
cases = [
# name, row_ids, ages, expected ages after update
('single_first_file', [0], [100], [100, 30, 35, 40, 45]),
('single_second_file', [1], [31], [25, 31, 35, 40, 45]),
('one_per_file', [1, 2], [200, 300], [25, 200, 300, 40, 45]),
('non_consecutive', [0, 2, 4], [100, 300, 500], [100, 30, 300, 40, 500]),
]
for name, row_ids, ages, expected in cases:
with self.subTest(case=name):
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': row_ids,
'age': ages,
}), ['age'])
self.assertEqual(
expected,
self._read_all(table)['age'].to_pylist(),
)
def test_multiple_sequential_single_column_updates(self):
"""Two sequential updates on different single columns compose."""
table = self._create_seeded_table()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 0, 2, 3, 4],
'age': [31, 26, 36, 41, 46],
}), ['age'])
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 0, 2, 3, 4],
'city': ['Los Angeles', 'New York', 'Chicago', 'Phoenix', 'Houston'],
}), ['city'])
result = self._read_all(table)
self.assertEqual([26, 31, 36, 41, 46], result['age'].to_pylist())
self.assertEqual(
['New York', 'Los Angeles', 'Chicago', 'Phoenix', 'Houston'],
result['city'].to_pylist(),
)
def test_sequential_partial_updates(self):
"""Sequential single-row updates accumulate correctly."""
table = self._create_seeded_table()
for row_id, age in [(0, 100), (2, 300), (4, 500)]:
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [row_id],
'age': [age],
}), ['age'])
self.assertEqual(
[100, 30, 300, 40, 500],
self._read_all(table)['age'].to_pylist(),
)
def test_update_partial_rows_across_two_files(self):
"""A single update modifies partial rows from both data files."""
table = self._create_table()
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2, 3, 4, 5],
'name': ['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
'age': [20, 25, 30, 35, 40],
'city': ['NYC', 'LA', 'Chicago', 'Houston', 'Phoenix'],
}, schema=self.pa_schema))
self._write_arrow(table, pa.Table.from_pydict({
'id': [6, 7, 8, 9, 10],
'name': ['Frank', 'Grace', 'Henry', 'Ivy', 'Jack'],
'age': [45, 50, 55, 60, 65],
'city': ['Seattle', 'Boston', 'Denver', 'Miami', 'Atlanta'],
}, schema=self.pa_schema))
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [1, 3, 6, 8],
'age': [100, 200, 300, 400],
'name': ['Updated_Bob', 'Updated_David', 'Updated_Grace', 'Updated_Ivy'],
}), ['age', 'name'])
result = self._read_all(table)
self.assertEqual(
[20, 100, 30, 200, 40, 45, 300, 55, 400, 65],
result['age'].to_pylist(),
)
self.assertEqual(
['Alice', 'Updated_Bob', 'Charlie', 'Updated_David', 'Eve',
'Frank', 'Updated_Grace', 'Henry', 'Updated_Ivy', 'Jack'],
result['name'].to_pylist(),
)
# Untouched columns
self.assertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
result['id'].to_pylist())
self.assertEqual(
['NYC', 'LA', 'Chicago', 'Houston', 'Phoenix',
'Seattle', 'Boston', 'Denver', 'Miami', 'Atlanta'],
result['city'].to_pylist(),
)
def test_large_table_partial_column_updates(self):
"""4-step update sequence on a 2000-row / 2-file table.
Covers: single-column / multi-column / both-files updates while
verifying that untouched columns stay intact.
"""
num_row = 1000
table = self._create_table()
# 2 commits, num_row rows each
self._write_arrow(table, pa.Table.from_pydict({
'id': list(range(num_row)),
'name': [f'Name_{i}' for i in range(num_row)],
'age': [20 + i for i in range(num_row)],
'city': [f'City_{i}' for i in range(num_row)],
}, schema=self.pa_schema))
self._write_arrow(table, pa.Table.from_pydict({
'id': list(range(num_row, num_row * 2)),
'name': [f'Name_{i}' for i in range(num_row, num_row * 2)],
'age': [20 + num_row + i for i in range(num_row)],
'city': [f'City_{i}' for i in range(num_row, num_row * 2)],
}, schema=self.pa_schema))
expected_ids = list(range(num_row * 2))
expected_names = [f'Name_{i}' for i in range(num_row * 2)]
expected_ages = ([20 + i for i in range(num_row)]
+ [20 + num_row + i for i in range(num_row)])
# 1) update id col, 1 row
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [5], 'id': [999],
}), ['id'])
expected_ids[5] = 999
# 2) update id+name cols, 2 rows
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [3, 15],
'id': [888, 777],
'name': ['Updated_Name_3', 'Updated_Name_15'],
}), ['id', 'name'])
expected_ids[3], expected_ids[15] = 888, 777
expected_names[3] = 'Updated_Name_3'
expected_names[15] = 'Updated_Name_15'
# 3) update name col, 1 row
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [12], 'name': ['NewName_12'],
}), ['name'])
expected_names[12] = 'NewName_12'
# 4) update age col, 4 rows across both files
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': [0, 5, 10, 15],
'age': [100, 105, 110, 115],
}), ['age'])
for r, a in [(0, 100), (5, 105), (10, 110), (15, 115)]:
expected_ages[r] = a
result = self._read_all(table)
self.assertEqual(expected_ids, result['id'].to_pylist())
self.assertEqual(expected_names, result['name'].to_pylist())
self.assertEqual(expected_ages, result['age'].to_pylist())
# city was never touched
self.assertEqual(
[f'City_{i}' for i in range(num_row * 2)],
result['city'].to_pylist(),
)
def test_update_with_large_file(self):
"""Updates disable both size- and row-based rolling."""
from pypaimon.schema.schema_change import SetOption
N = 5000
schema = pa.schema([('id', pa.int64()), ('name', pa.string())])
opts = {
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
'write-only': 'true',
}
table = self._create_table(pa_schema=schema, options=opts)
table_identifier = table.identifier
self._write_arrow(table, pa.table({
'id': list(range(N)),
'name': [''.join(random.choices(string.ascii_letters, k=200))
for _ in range(N)],
}))
self.catalog.alter_table(
table_identifier, [
SetOption('target-file-size', '10kb'),
SetOption('target-file-row-num', '1'),
]
)
table = self.catalog.get_table(table_identifier)
msgs = self._do_update(table, pa.table({
'_ROW_ID': pa.array(list(range(N)), type=pa.int64()),
'name': [''.join(random.choices(string.ascii_letters, k=200))
for _ in range(N)],
}), ['name'])
all_files = [f for m in msgs for f in m.new_files]
self.assertEqual(1, len(all_files),
"Update should produce exactly one file per group")
self.assertEqual(0, all_files[0].first_row_id)
self.assertEqual(N, all_files[0].row_count)
# ------------------------------------------------------------------
# Validation tests
# ------------------------------------------------------------------
def test_nonexistent_column_raises(self):
table = self._create_seeded_table()
bad = pa.Table.from_pydict({
'_ROW_ID': [0, 1, 2, 3, 4],
'nonexistent_column': [100, 200, 300, 400, 500],
})
with self.assertRaises(ValueError) as ctx:
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['nonexistent_column'])
self._apply_update(tu, bad, self._next_commit_id())
self.assertIn('not in table schema', str(ctx.exception))
def test_missing_row_id_column_raises(self):
table = self._create_seeded_table()
bad = pa.Table.from_pydict({'age': [26, 27, 28, 29, 30]})
with self.assertRaises(ValueError) as ctx:
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['age'])
self._apply_update(tu, bad, self._next_commit_id())
self.assertIn('_ROW_ID column', str(ctx.exception))
def test_invalid_row_id_raises(self):
"""row_id outside valid row_id ranges raises."""
table = self._create_seeded_table()
cases = [
('out_of_range_high', [0, 10], [26, 100]),
('negative', [-1, 0], [100, 26]),
]
for name, row_ids, ages in cases:
with self.subTest(case=name):
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['age'])
bad = pa.Table.from_pydict({'_ROW_ID': row_ids, 'age': ages})
with self.assertRaises(ValueError) as ctx:
self._apply_update(tu, bad, self._next_commit_id())
self.assertIn('does not belong to any valid range', str(ctx.exception))
def test_duplicate_row_id_raises(self):
table = self._create_seeded_table()
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['age'])
with self.assertRaises(ValueError) as ctx:
self._apply_update(
tu,
pa.Table.from_pydict({
'_ROW_ID': [0, 0, 1],
'age': [100, 200, 300],
}),
self._next_commit_id(),
)
self.assertIn('duplicate _ROW_ID', str(ctx.exception))
def test_update_deleted_row_id_raises(self):
"""Updating a row_id that fell into a hole after truncate raises."""
partitioned_schema = pa.schema([
('id', pa.int32()),
('name', pa.string()),
('age', pa.int32()),
('region', pa.string()),
])
table = self._create_table(
pa_schema=partitioned_schema,
partition_keys=['region'],
)
self._write_arrow(table, pa.Table.from_pydict({
'id': pa.array([1, 2, 3], type=pa.int32()),
'name': ['A', 'B', 'C'],
'age': pa.array([10, 20, 30], type=pa.int32()),
'region': ['US', 'US', 'US'],
}, schema=partitioned_schema))
self._write_arrow(table, pa.Table.from_pydict({
'id': pa.array([4, 5], type=pa.int32()),
'name': ['D', 'E'],
'age': pa.array([40, 50], type=pa.int32()),
'region': ['EU', 'EU'],
}, schema=partitioned_schema))
wb = table.new_batch_write_builder()
tc = wb.new_commit()
tc.truncate_partitions([{'region': 'US'}])
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['age'])
with self.assertRaises(ValueError) as ctx:
self._apply_update(
tu,
pa.Table.from_pydict({'_ROW_ID': [0], 'age': [99]}),
self._next_commit_id(),
)
self.assertIn('does not belong to any valid range', str(ctx.exception))
# ------------------------------------------------------------------
# Concurrency tests
# ------------------------------------------------------------------
def _run_concurrent_updates(self, table, thread_specs, max_retries):
"""Run a batch of concurrent updates with conflict-retry; return the
commit order (``thread_index`` of the winning commit appended last)."""
errors = []
completion_order = []
lock = threading.Lock()
retry_marker = (
"multiple 'MERGE INTO' operations have encountered conflicts"
)
def worker(idx, spec):
# Tag each thread's commits so the durable winner can be read back from the
# latest snapshot's commit_user (the order threads return in is not the commit order).
worker_table = table.copy({'commit.user-prefix': 'w%d' % idx})
for _ in range(max_retries):
try:
self._do_update(worker_table, pa.Table.from_pydict({
'_ROW_ID': spec['row_ids'],
'age': spec['ages'],
}), ['age'])
with lock:
completion_order.append(idx)
return
except Exception as e:
if retry_marker not in str(e):
errors.append(f"Thread-{idx} unexpected error: {e}")
return
errors.append(f"Thread-{idx} did not succeed in {max_retries} retries")
threads = [threading.Thread(target=worker, args=(i, s))
for i, s in enumerate(thread_specs)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=120)
self.assertEqual([], errors)
self.assertEqual(len(thread_specs), len(completion_order),
"Not all threads committed successfully")
return completion_order
def test_concurrent_updates_disjoint_rows(self):
"""Disjoint per-thread updates all materialise into the final state."""
table = self._create_seeded_table()
specs = [
{'row_ids': [0, 1], 'ages': [100, 200]},
{'row_ids': [2, 3], 'ages': [300, 400]},
{'row_ids': [4], 'ages': [500]},
]
self._run_concurrent_updates(table, specs, max_retries=20)
self.assertEqual(
[100, 200, 300, 400, 500],
self._read_all(table)['age'].to_pylist(),
)
def test_concurrent_updates_overlapping_rows_last_writer_wins(self):
"""When threads compete on the same rows, the final commit wins."""
table = self._create_seeded_table()
specs = [
{'row_ids': [0, 1, 2], 'ages': [101, 201, 301]},
{'row_ids': [0, 1, 2], 'ages': [102, 202, 302]},
{'row_ids': [0, 1, 2], 'ages': [103, 203, 303]},
]
self._run_concurrent_updates(table, specs, max_retries=30)
ages = self._read_all(table)['age'].to_pylist()
# The real winner is the thread whose commit produced the latest snapshot.
winner = int(table.snapshot_manager().get_latest_snapshot().commit_user[1:].split('_')[0])
self.assertEqual(specs[winner]['ages'], ages[:3])
self.assertEqual([40, 45], ages[3:])
def test_update_list_and_map_columns(self):
list_map_schema = pa.schema([
('id', pa.int32()),
('tags', pa.list_(pa.string())),
('meta', pa.map_(pa.string(), pa.string())),
])
table = self._create_table(pa_schema=list_map_schema)
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2, 3],
'tags': [['a', 'b'], ['c'], ['d', 'e']],
'meta': [[('k1', 'v1')], [('k2', 'v2')], [('k3', 'v3')]],
}, schema=list_map_schema))
rb = table.new_read_builder().with_projection(
['id', '_ROW_ID'])
rid_result = rb.new_read().to_arrow(
rb.new_scan().plan().splits()).sort_by('id')
row_ids = rid_result['_ROW_ID'].to_pylist()
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[0], row_ids[2]],
type=pa.int64()),
'tags': [['x', 'y'], ['z']],
'meta': [[('k1', 'new1')], [('k3', 'new3')]],
}), ['tags', 'meta'])
result = self._read_all(table).sort_by('id')
self.assertEqual(
[['x', 'y'], ['c'], ['z']],
result['tags'].to_pylist())
self.assertEqual(
[[('k1', 'new1')], [('k2', 'v2')], [('k3', 'new3')]],
result['meta'].to_pylist())
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[1]], type=pa.int64()),
'meta': [{'k2': 'dict_val'}],
}), ['meta'])
result2 = self._read_all(table).sort_by('id')
self.assertEqual(
[[('k1', 'new1')], [('k2', 'dict_val')], [('k3', 'new3')]],
result2['meta'].to_pylist())
with self.assertRaisesRegex(ValueError, "schema-less dict"):
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[0], row_ids[2]],
type=pa.int64()),
'meta': [{'a': '1'}, {'b': '2'}],
}), ['meta'])
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[0], row_ids[2]],
type=pa.int64()),
'meta': pa.array(
[[('a', '1')], [('b', '2')]],
type=pa.map_(pa.string(), pa.string())),
}), ['meta'])
result3 = self._read_all(table).sort_by('id')
self.assertEqual(
[[('a', '1')], [('k2', 'dict_val')], [('b', '2')]],
result3['meta'].to_pylist())
with self.assertRaisesRegex(ValueError, "schema-less dict"):
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[0]], type=pa.int64()),
'meta': [{'a': None}],
}), ['meta'])
self._do_update(table, pa.Table.from_pydict({
'_ROW_ID': pa.array([row_ids[0]], type=pa.int64()),
'meta': [[('a', None)]],
}, schema=pa.schema([
('_ROW_ID', pa.int64()),
('meta', pa.map_(pa.string(), pa.string())),
])), ['meta'])
result4 = self._read_all(table).sort_by('id')
self.assertEqual(
[[('a', None)], [('k2', 'dict_val')], [('b', '2')]],
result4['meta'].to_pylist())
# ======================================================================
# Mode-specific mixins (add the ``update_by_arrow_with_row_id`` primitive)
# ======================================================================
class _BatchModeMixin(BatchModeMixin):
def _apply_update(self, table_update, data, cid):
return table_update.update_by_arrow_with_row_id(data)
def _apply_update_by_predicate(
self, table_update, predicate, assignments, cid):
return table_update.update_by_predicate(predicate, assignments)
def _apply_delete_by_predicate(self, table_update, predicate, cid):
return table_update.delete_by_predicate(predicate)
def _apply_delete_by_row_id(self, table_update, row_ids, cid):
return table_update.delete_by_row_id(row_ids)
class _StreamModeMixin(StreamModeMixin):
def _apply_update(self, table_update, data, cid):
return table_update.update_by_arrow_with_row_id(data, cid)
def _apply_update_by_predicate(
self, table_update, predicate, assignments, cid):
return table_update.update_by_predicate(
predicate,
assignments,
cid,
)
def _apply_delete_by_predicate(self, table_update, predicate, cid):
return table_update.delete_by_predicate(predicate, cid)
def _apply_delete_by_row_id(self, table_update, row_ids, cid):
return table_update.delete_by_row_id(row_ids, cid)
# ======================================================================
# Concrete test classes
# ======================================================================
class TableUpdateBatchTest(_BatchModeMixin, _TableUpdateTestBase, unittest.TestCase):
"""All shared update tests under batch (``BatchWriteBuilder``) semantics."""
def test_update_by_row_id_aborts_files_after_prepare_commit_failure(self):
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
table_schema = pa.schema([
('id', pa.int32()),
('age', pa.int32()),
('picture', pa.large_binary()),
])
table = self._create_table(pa_schema=table_schema)
self._write_arrow(table, pa.Table.from_pydict({
'id': [1, 2],
'age': [10, 20],
'picture': [b'blob-1', b'blob-2'],
}, schema=table_schema))
rb = table.new_read_builder().with_projection(['id', '_ROW_ID'])
row_ids = rb.new_read().to_arrow(
rb.new_scan().plan().splits()).sort_by('id')['_ROW_ID']
before_files = self._list_table_files(table)
def fail_after_prepare_commit(
new_files, first_row_id, column_names, blob_columns):
raise RuntimeError("forced failure after prepare_commit")
wb = self._make_write_builder(table)
tu = wb.new_update().with_update_type(['age', 'picture'])
with mock.patch.object(
TableUpdateByRowId,
'_assign_update_file_metadata',
new=staticmethod(fail_after_prepare_commit)):
with self.assertRaisesRegex(
RuntimeError, "forced failure after prepare_commit"):
self._apply_update(tu, pa.Table.from_pydict({
'_ROW_ID': [row_ids[0].as_py()],
'age': [99],
'picture': [b'updated-blob'],
}, schema=pa.schema([
('_ROW_ID', pa.int64()),
('age', pa.int32()),
('picture', pa.large_binary()),
])), self._next_commit_id())
self.assertEqual(before_files, self._list_table_files(table))
@staticmethod
def _list_table_files(table):
return {
os.path.relpath(os.path.join(root, name), table.table_path)
for root, _dirs, files in os.walk(table.table_path)
for name in files
}
class TableUpdateStreamTest(_StreamModeMixin, _TableUpdateTestBase, unittest.TestCase):
"""All shared update tests under stream (``StreamWriteBuilder``) semantics,
plus stream-only multi-commit scenarios that are impossible to express
under :class:`BatchTableCommit` (which forbids re-committing).
"""
# ------------------------------------------------------------------
# Stream-only helpers
# ------------------------------------------------------------------
def _stream_commit_age_updates_by_row_id(self, tu, tc, commit_ids, row_age_pairs):
"""Apply one ``age`` update per ``commit_id``; ``tu`` is already
``with_update_type(['age'])``."""
for cid, (row_id, age) in zip(commit_ids, row_age_pairs):
msgs = tu.update_by_arrow_with_row_id(
pa.Table.from_pydict({'_ROW_ID': [row_id], 'age': [age]}),
cid,
)
tc.commit(msgs, cid)
# ------------------------------------------------------------------
# Stream-only tests
# ------------------------------------------------------------------
def test_stream_multi_commit_on_one_write_builder(self):
"""A single ``StreamWriteBuilder`` drives many update+commit cycles
with reused ``tu`` and ``tc`` instances. Each commit produces its own
snapshot tagged with the caller-supplied ``commit_identifier`` under
a stable ``commit_user`` — the core contract distinguishing stream
from batch mode.
Parameterised over both contiguous and sparse identifier sequences
to catch any accidental coupling between ``commit_identifier`` and
``snapshot_id`` ordering.
"""
# (row_id, age) for each of the 3 commits — same triplet for both runs.
per_commit_rows = [(0, 100), (2, 300), (4, 500)]
expected_ages = [100, 30, 300, 40, 500]
for case_name, commit_ids in [
('contiguous', [1, 2, 3]),
('sparse', [42, 100, 1000]),
]:
with self.subTest(case=case_name):
table = self._create_seeded_table()
wb, tc, base_snapshot_id = self._stream_commit_session(table)
tu = wb.new_update().with_update_type(['age'])
self._stream_commit_age_updates_by_row_id(
tu, tc, commit_ids, per_commit_rows,
)
tc.close()
result = self._read_all(table)
self.assertEqual(expected_ages, result['age'].to_pylist())
self.assertEqual([1, 2, 3, 4, 5], result['id'].to_pylist())
self.assertEqual(
['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
result['name'].to_pylist(),
)
self.assertEqual(
['NYC', 'LA', 'Chicago', 'Houston', 'Phoenix'],
result['city'].to_pylist(),
)
self._assert_stream_builder_snapshots(
table, wb, base_snapshot_id, commit_ids,
)
def test_stream_commit_instance_accepts_messages_from_different_updates(self):
"""``StreamTableCommit`` may be committed many times, and each
commit may carry messages from a *different* ``StreamTableUpdate``
instance (different ``update_cols``). The one-shot restriction
that applies to :class:`BatchTableCommit` does not apply here.
"""
table = self._create_seeded_table()
wb, tc, base_snapshot_id = self._stream_commit_session(table)
# First update: only 'age' projection, fresh tu.
tu1 = wb.new_update().with_update_type(['age'])
msgs1 = tu1.update_by_arrow_with_row_id(
pa.Table.from_pydict({'_ROW_ID': [0], 'age': [111]}), 1,
)
tc.commit(msgs1, 1)
# Second update: different 'city' projection, fresh tu, same tc.
tu2 = wb.new_update().with_update_type(['city'])
msgs2 = tu2.update_by_arrow_with_row_id(
pa.Table.from_pydict({'_ROW_ID': [0], 'city': ['Beijing']}), 2,
)
tc.commit(msgs2, 2)
tc.close()
result = self._read_all(table)
self.assertEqual([111, 30, 35, 40, 45], result['age'].to_pylist())
self.assertEqual(
['Beijing', 'LA', 'Chicago', 'Houston', 'Phoenix'],
result['city'].to_pylist(),
)
self.assertEqual([1, 2, 3, 4, 5], result['id'].to_pylist())
self.assertEqual(
['Alice', 'Bob', 'Charlie', 'David', 'Eve'],
result['name'].to_pylist(),
)
self._assert_stream_builder_snapshots(
table, wb, base_snapshot_id, [1, 2],
)
def test_stream_interleaved_write_and_update_on_same_builder(self):
"""A single stream builder may perform an initial write followed by
an update on the same ``tc``, each tagged with its own
``commit_identifier`` that propagates to its own snapshot.
"""
table = self._create_table()
wb, tc, base_snapshot_id = self._stream_commit_session(table)
tw = wb.new_write()
tw.write_arrow(pa.Table.from_pydict({
'id': [1, 2, 3],
'name': ['A', 'B', 'C'],
'age': [10, 20, 30],
'city': ['X', 'Y', 'Z'],
}, schema=self.pa_schema))
tc.commit(tw.prepare_commit(1), 1)
tw.close()
tu = wb.new_update().with_update_type(['age'])
msgs = tu.update_by_arrow_with_row_id(
pa.Table.from_pydict({'_ROW_ID': [0, 2], 'age': [111, 333]}), 2,
)
tc.commit(msgs, 2)
tc.close()
result = self._read_all(table)
self.assertEqual([111, 20, 333], result['age'].to_pylist())
self.assertEqual([1, 2, 3], result['id'].to_pylist())
self.assertEqual(['A', 'B', 'C'], result['name'].to_pylist())
self.assertEqual(['X', 'Y', 'Z'], result['city'].to_pylist())
self._assert_stream_builder_snapshots(
table, wb, base_snapshot_id, [1, 2],
)
if __name__ == '__main__':
unittest.main()