blob: 7165cebedc50a91a2f73bf5ed1a093ade7c05ad9 [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.
"""Logical upsert keys must not depend on Arrow widths or dictionaries."""
import datetime
import mmap
import os
import struct
import sys
from contextlib import ExitStack
from decimal import Decimal
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
from pypaimon.schema.data_types import PyarrowFieldParser
pytestmark = pytest.mark.native_plan
_KEYS = [
(pa.int8(), pa.int64(), 1, 2),
(pa.int16(), pa.uint32(), 1, 2),
(pa.int32(), pa.int64(), 1, 2),
(pa.int64(), pa.uint64(), 1, 2),
(pa.float32(), pa.float64(), 1.5, -2.25),
(pa.float64(), pa.float32(), 1.5, -2.25),
(pa.float64(), pa.dictionary(pa.int8(), pa.float32()), 1.5, -2.25),
(pa.binary(), pa.large_binary(), b'\xff\0', b'other'),
(pa.large_binary(), pa.binary(), b'\xff\0', b'other'),
(pa.binary(), pa.dictionary(pa.int8(), pa.large_binary()), b'\xff\0', b'other'),
(pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), Decimal('-2.50')),
(pa.date32(), pa.date64(), datetime.date(1969, 12, 31), datetime.date(1970, 1, 2)),
(pa.time32('ms'), pa.time64('us'), datetime.time(1, 2, 3, 400000), datetime.time(4, 5, 6)),
(pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(1969, 12, 31, 23, 59, 59, 999000),
datetime.datetime(1970, 1, 2)),
(pa.timestamp('us'), pa.timestamp('ms'), datetime.datetime(1969, 12, 31, 23, 59, 59, 999000),
datetime.datetime(1970, 1, 2)),
(pa.timestamp('us', 'UTC'), pa.timestamp('ms', 'America/New_York'),
datetime.datetime(2024, 7, 1, tzinfo=datetime.timezone.utc),
datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)),
(pa.string(), pa.dictionary(pa.int8(), pa.string()), '中文', 'other'),
(pa.int32(), pa.dictionary(pa.int16(), pa.int64()), 1, 2),
(pa.binary(), pa.dictionary(pa.int8(), pa.binary()), b'key', b'other'),
]
_NUMERIC_KEYS = [
(pa.int32(), pa.float64(), 1, 1.0, 2),
(pa.int64(), pa.float64(), 2 ** 53 + 2, float(2 ** 53 + 2), 2 ** 53 + 4),
(pa.float32(), pa.int64(), 1.0, 1, 2.0),
(pa.float64(), pa.int64(), float(2 ** 53 + 2), 2 ** 53 + 2, float(2 ** 53 + 4)),
(pa.decimal128(10, 2), pa.int64(), Decimal('1.00'), 1, Decimal('2.00')),
(pa.int32(), pa.decimal128(15, 4), 1, Decimal('1.0000'), 2),
(pa.decimal128(10, 2), pa.float64(), Decimal('1.50'), 1.5, Decimal('2.25')),
(pa.float64(), pa.decimal128(10, 2), 1.5, Decimal('1.50'), 2.25),
(pa.decimal128(38, 38), pa.float64(), Decimal('0'), 0.0, Decimal('1E-38')),
(pa.float32(), pa.decimal256(40, 4), 1.5, Decimal('1.5000'), 2.25),
(pa.int64(), pa.dictionary(pa.int8(), pa.float64()), 1, 1.0, 2),
(pa.float64(), pa.dictionary(pa.int8(), pa.decimal128(10, 2)), 1.5, Decimal('1.50'), 2.25),
(pa.float64(), pa.decimal256(76, 55), 0.1, Decimal.from_float(0.1), 0.25),
(pa.float64(), pa.decimal256(76, 70), 2.0 ** -70, Decimal.from_float(2.0 ** -70), 2.0 ** -69),
(pa.float64(), pa.decimal256(76, -20), 1e20, Decimal('1E+20'), 2e20),
(pa.float32(), pa.uint32(), float(2 ** 24 + 2), 2 ** 24 + 2, float(2 ** 24 + 4)),
(pa.float64(), pa.uint64(), float(2 ** 64 - 2048), 2 ** 64 - 2048, 1.0),
(pa.decimal128(38, 38), pa.int64(), Decimal('0'), 0, Decimal('1E-38')),
]
for _width in (32, 64):
_factory = getattr(pa, 'decimal{}'.format(_width), None)
if _factory is not None:
_NUMERIC_KEYS.append((pa.float64(), _factory(9, 2), 1.5, Decimal('1.50'), 2.25))
def _array(values, data_type):
if pa.types.is_dictionary(data_type):
return pa.array(values, type=data_type.value_type).dictionary_encode().cast(data_type)
return pa.array(values, type=data_type)
def _table(tmp_path, native, key_type, key, other, partitioned=False):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('key', key_type), ('pt', pa.string()), ('value', pa.int32()), ('keep', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, partition_keys=['pt'] if partitioned else None, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': str(native).lower(),
}), False)
table = catalog.get_table('default.t')
schema = PyarrowFieldParser.from_paimon_schema(table.fields)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'key': [key, key, other, None], 'pt': ['a', 'a', 'b', 'a'],
'value': [0] * 4, 'keep': [0, 1, 2, 3]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
return table, schema
def _upsert(table, source, native, stream, columns=('value',)):
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(
PythonTableUpsert, '_upsert_partition', side_effect=AssertionError('Python key matching')))
update = builder.new_update()
if columns is not None:
update.with_update_type(list(columns))
messages = (update.upsert_by_arrow_with_key(source, ['key'], 37) if stream
else update.upsert_by_arrow_with_key(source, ['key']))
commit = builder.new_commit()
try:
commit.commit(messages, 37) if stream else commit.commit(messages)
finally:
commit.close()
def _rows(table):
for native in (False, True):
view = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
builder = view.new_read_builder().with_projection(['key', 'pt', 'value', 'keep', '_ROW_ID'])
rows = builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
yield sorted(rows, key=lambda row: row['keep'])
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('key_type,input_type,key,other', _KEYS)
def test_upsert_normalizes_match_keys_without_changing_row_identity(
tmp_path, native, stream, key_type, input_type, key, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
# Different dictionaries and the last duplicate occupy separate chunks.
chunks = [pa.table({'key': _array([value], input_type), 'value': pa.array([number], type=pa.int32())})
for value, number in [(key, 1), (None, 9), (key, 42)]]
_upsert(table, pa.concat_tables(chunks), native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0, 9]
assert [{k: v for k, v in row.items() if k != 'value'} for row in rows] == [
{k: v for k, v in row.items() if k != 'value'} for row in before]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('key_type,input_type,key,input_key,other', _NUMERIC_KEYS)
def test_numeric_keys_match_by_exact_value_across_arrow_types(
tmp_path, native, stream, key_type, input_type, key, input_key, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
chunks = [pa.table({'key': _array([value], input_type), 'value': pa.array([number], type=pa.int32())})
for value, number in [(input_key, 1), (None, 9), (input_key, 42)]]
_upsert(table, pa.concat_tables(chunks), native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0, 9]
assert [{k: v for k, v in row.items() if k != 'value'} for row in rows] == [
{k: v for k, v in row.items() if k != 'value'} for row in before]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('key_type,input_type,key,invalid,other', [
(pa.int32(), pa.float64(), 1, 1.00000001, 2),
(pa.int64(), pa.float64(), 2 ** 63 - 1, float(2 ** 63), 2),
(pa.float64(), pa.int64(), float(2 ** 53), 2 ** 53 + 1, 2.0),
(pa.decimal128(10, 2), pa.float64(), Decimal('0.10'), 0.1, Decimal('0.20')),
(pa.float64(), pa.decimal128(10, 2), 0.1, Decimal('0.10'), 0.2),
])
def test_numeric_key_conversion_cannot_round_into_an_existing_row(
tmp_path, native, key_type, input_type, key, invalid, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _array([invalid], input_type), 'value': pa.array([42], type=pa.int32())})
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, False)
for rows in _rows(table):
assert rows == before
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('key_type,input_type,key,input_key,other', _NUMERIC_KEYS[:8])
def test_mixed_numeric_upsert_validates_original_unmatched_rows_before_updates(
tmp_path, native, key_type, input_type, key, input_key, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _array([input_key, input_key + 4], input_type),
'value': pa.array([42, 99], type=pa.int32())})
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, False)
for rows in _rows(table):
assert rows == before
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('key_type,input_type,key,input_key,other', _NUMERIC_KEYS[:3] + _NUMERIC_KEYS[4:8])
def test_full_row_numeric_upsert_preserves_row_identity_and_payload_casts(
tmp_path, native, stream, key_type, input_type, key, input_key, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _array([input_key], input_type), 'pt': ['updated'],
'value': pa.array([42], type=pa.int32()), 'keep': pa.array([99], type=pa.int32())})
_upsert(table, source, native, stream, columns=None)
for rows in _rows(table):
by_id = {row['_ROW_ID']: row for row in rows}
for original in before:
expected = dict(original)
if original['key'] == key:
expected.update(pt='updated', value=42, keep=99)
assert by_id[original['_ROW_ID']] == expected
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
def test_exact_match_does_not_relax_updated_key_assignment_range(tmp_path, native):
key = float(2 ** 53 + 2)
table, _ = _table(tmp_path, native, pa.float64(), key, float(2 ** 53 + 4))
before = next(_rows(table))
source = pa.table({'key': pa.array([2 ** 53 + 2], type=pa.int64()), 'pt': ['updated'],
'value': pa.array([42], type=pa.int32()), 'keep': pa.array([99], type=pa.int32())})
# Matching only needs an exact numeric identity. Updating the key payload
# still uses the ordinary integer-to-double assignment range (<= 2^53).
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, False, columns=None)
for rows in _rows(table):
assert rows == before
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('key_type,input_type,key,other', [
(pa.int32(), pa.int64(), 1, 2),
(pa.float64(), pa.float32(), 1.5, -2.25),
(pa.binary(), pa.large_binary(), b'key', b'other'),
])
def test_partition_dictionaries_and_equivalent_key_layouts_share_one_identity(
tmp_path, native, stream, key_type, input_type, key, other):
table, _ = _table(tmp_path, native, key_type, key, other, partitioned=True)
source = pa.table({'key': _array([key, other, key], input_type),
'pt': _array(['a', 'b', 'a'], pa.dictionary(pa.int8(), pa.string())),
'value': pa.array([1, 7, 42], type=pa.int32())})
before = next(_rows(table))
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 7, 0]
assert [row['_ROW_ID'] for row in rows] == [row['_ROW_ID'] for row in before]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('key_type,input_type,key,other,new_key', [
(pa.int32(), pa.int64(), 1, 2, 3),
(pa.float32(), pa.float64(), 1.5, -2.25, 3.5),
(pa.float64(), pa.float32(), 1.5, -2.25, 3.5),
(pa.binary(), pa.large_binary(), b'key', b'other', b'new'),
(pa.large_binary(), pa.binary(), b'key', b'other', b'new'),
(pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.00'), Decimal('2.00'), Decimal('3.00')),
(pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 3)),
])
def test_new_rows_retain_original_key_write_type_validation(
tmp_path, native, key_type, input_type, key, other, new_key):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _array([new_key], input_type), 'value': pa.array([42], type=pa.int32())})
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, False)
for rows in _rows(table):
assert rows == before
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('key_type,input_type,key,other,invalid', [
(pa.int8(), pa.int64(), 1, 2, 256),
(pa.float32(), pa.float64(), 1.0, 2.0, 1.00000001),
(pa.int64(), pa.uint64(), 1, 2, 2 ** 63),
(pa.decimal128(10, 2), pa.decimal128(15, 4), Decimal('1.25'), Decimal('2.50'), Decimal('1.2501')),
(pa.timestamp('ms'), pa.timestamp('us'), datetime.datetime(2024, 1, 1),
datetime.datetime(2024, 1, 2), datetime.datetime(2024, 1, 1, 0, 0, 0, 1)),
])
def test_lossy_key_conversion_cannot_update_an_existing_row(
tmp_path, native, key_type, input_type, key, other, invalid):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _array([invalid], input_type), 'value': pa.array([42], type=pa.int32())})
with pytest.raises((ValueError, TypeError, NotImplementedError)):
_upsert(table, source, native, False)
for rows in _rows(table):
assert rows == before
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('key_type,key,other', [
(pa.int32(), 1, 2), (pa.string(), '中文', 'other'), (pa.binary(), b'key', b'other'),
])
def test_dictionary_keys_can_also_be_written_by_full_row_updates(tmp_path, native, stream, key_type, key, other):
table, _ = _table(tmp_path, native, key_type, key, other)
before = next(_rows(table))
source = pa.table({'key': _dictionary_array([key], key_type), 'pt': ['updated'],
'value': pa.array([42], type=pa.int32()), 'keep': pa.array([99], type=pa.int32())})
_upsert(table, source, native, stream, columns=None)
for rows in _rows(table):
by_id = {row['_ROW_ID']: row for row in rows}
for original in before:
expected = dict(original)
if original['key'] == key:
expected.update(pt='updated', value=42, keep=99)
assert by_id[original['_ROW_ID']] == expected
def _dictionary_array(values, value_type):
return pa.array(values, type=value_type).dictionary_encode()
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('partitioned', [False, True])
def test_untyped_null_keys_match_only_existing_null_key_rows(tmp_path, native, stream, partitioned):
table, _ = _table(tmp_path, native, pa.int32(), 1, 2, partitioned=partitioned)
source = pa.table({'key': pa.nulls(1), 'value': pa.array([9], type=pa.int32())})
if partitioned:
source = source.append_column('pt', pa.array(['a']))
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [0, 0, 0, 9]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
def test_dictionary_null_values_and_null_indices_share_upsert_identity(tmp_path, native, stream):
table, _ = _table(tmp_path, native, pa.string(), 'key', 'other')
keys = pa.DictionaryArray.from_arrays(pa.array([0, 1, None], type=pa.int8()), pa.array([None, 'key']))
source = pa.table({'key': keys, 'value': pa.array([9, 42, 11], type=pa.int32())})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0, 11]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
def test_decimal_zero_and_null_keys_rescale_across_the_full_scale_range(tmp_path, native, stream):
table, _ = _table(tmp_path, native, pa.decimal128(38, 38), Decimal('0'), Decimal('1e-38'))
source = pa.table({'key': pa.array([Decimal('0'), None], type=pa.decimal128(38, -2)),
'value': pa.array([42, 9], type=pa.int32())})
_upsert(table, source, native, stream)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0, 9]
@pytest.mark.skipif(os.name != 'posix' or sys.maxsize <= 2 ** 32, reason='Requires 64-bit POSIX mmap')
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
def test_large_binary_key_slice_after_two_gib_matches_visible_bytes(tmp_path, native):
table, _ = _table(tmp_path, native, pa.binary(), b'ab', b'other')
before = next(_rows(table))
start = 2 ** 31 + 17
# Reserve address space without allocating or reading a 2 GiB prefix.
memory = mmap.mmap(-1, start + 2)
memory[start:start + 2] = b'ab'
keys = pa.Array.from_buffers(pa.large_binary(), 1, [
None, pa.py_buffer(struct.pack('<qq', start, start + 2)), pa.py_buffer(memory)])
source = pa.table({'key': keys, 'value': pa.array([42], type=pa.int32())})
_upsert(table, source, native, False)
for rows in _rows(table):
assert [row['value'] for row in rows] == [42, 42, 0, 0]
assert [{k: v for k, v in row.items() if k != 'value'} for row in rows] == [
{k: v for k, v in row.items() if k != 'value'} for row in before]
@pytest.mark.skipif(os.name != 'posix' or sys.maxsize <= 2 ** 32, reason='Requires 64-bit POSIX mmap')
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('mixed', [False, True], ids=['all_null', 'mixed'])
@pytest.mark.parametrize('kind', ['binary', 'string'])
@pytest.mark.parametrize('encoded', [False, True], ids=['plain', 'dictionary'])
@pytest.mark.parametrize('payload', [b'ab', '中文'.encode(), b''], ids=['ascii', 'utf8', 'empty'])
@pytest.mark.parametrize('sliced', [False, True], ids=['zero_offset', 'sliced'])
def test_byte_match_keys_ignore_storage_owned_by_null_values(tmp_path, native, mixed, kind, encoded, payload, sliced):
text = kind == 'string'
key_type, input_type = (pa.string(), pa.large_string()) if text else (pa.binary(), pa.large_binary())
table, _ = _table(tmp_path, native, key_type, payload.decode() if text else payload, 'other' if text else b'other')
before = next(_rows(table))
start = 2 ** 31 + 17
memory = mmap.mmap(-1, start + len(payload))
memory[start:start + len(payload)] = payload
offsets = [0, start, start + len(payload)] if mixed else [0, start]
length = len(offsets) - 1
if sliced:
offsets.insert(0, 0)
keys = pa.Array.from_buffers(input_type, length, [
pa.py_buffer(b'\x05' if sliced else b'\x02'),
pa.array(offsets, type=pa.int64()).buffers()[1], pa.py_buffer(memory)],
null_count=1, offset=int(sliced))
if encoded:
keys = pa.DictionaryArray.from_arrays(pa.array(range(len(keys)), type=pa.int8()), keys)
source = pa.table({'key': keys, 'value': pa.array([9, 42] if mixed else [9], type=pa.int32())})
_upsert(table, source, native, False)
for rows in _rows(table):
assert [row['value'] for row in rows] == ([42, 42, 0, 9] if mixed else [0, 0, 0, 9])
assert [{k: v for k, v in row.items() if k != 'value'} for row in rows] == [
{k: v for k, v in row.items() if k != 'value'} for row in before]