blob: 0986d57d037900114dbc527c2a93897a545b6cf8 [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.
"""Temporal row keys must reach matching before any lossy construction."""
from contextlib import ExitStack
from datetime import date, datetime, time, timedelta, timezone
import sys
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.execution.native.write.native_update import NativeTableUpsert, _upsert_row_batches, create_native_upsert
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
_UNSAFE_KINDS = ['timestamp', 'seconds', 'local-zone', 'offset-precision', 'time', 'datetime-date',
'aware-time', 'naive-ltz', 'aware-ntz']
_TICK_KINDS = ['date-ticks', 'timestamp-ticks', 'time-ticks']
def _case(kind, exact=False):
micros = 123000 if exact else 123456
if kind == 'date-ticks':
return pa.date32(), date(2020, 1, 1), pa.int32(), 18262
if kind == 'timestamp-ticks':
return pa.timestamp('ms'), datetime(2020, 1, 1), pa.int64(), 1577836800000
if kind == 'time-ticks':
return pa.time32('ms'), time(12, 34, 56, 123000), pa.int32(), 45296123
if kind == 'date':
return pa.date32(), date(2020, 1, 1), pa.date32(), date(2020, 1, 1)
if kind == 'datetime-date':
return pa.date32(), date(2020, 1, 1), pa.timestamp('us'), datetime(2020, 1, 1)
if kind == 'aware-time':
return (pa.time32('ms'), time(12, 34, 56, 123000), pa.time64('us'),
time(12, 34, 56, 123000, tzinfo=timezone.utc))
if kind == 'naive-ltz':
return (pa.timestamp('ms', tz='UTC'), datetime(2020, 1, 1, tzinfo=timezone.utc),
pa.timestamp('us'), datetime(2020, 1, 1))
if kind == 'aware-ntz':
return (pa.timestamp('ms'), datetime(2020, 1, 1), pa.timestamp('us', tz='UTC'),
datetime(2020, 1, 1, tzinfo=timezone.utc))
if kind == 'offset-precision':
if sys.version_info < (3, 7):
pytest.skip('Sub-minute timezone offsets require Python 3.7 or newer')
initial = datetime(2020, 1, 1, microsecond=122000, tzinfo=timezone.utc)
value = datetime(2020, 1, 1, microsecond=123000, tzinfo=timezone(timedelta(microseconds=500)))
return pa.timestamp('ms', tz='UTC'), initial, pa.timestamp('us', tz='UTC'), value
if kind == 'time':
return pa.time32('ms'), time(12, 34, 56, 123000), pa.time64('us'), time(12, 34, 56, micros)
if kind == 'seconds':
value = datetime(2020, 1, 1, microsecond=0 if exact else micros)
return pa.timestamp('s'), datetime(2020, 1, 1), pa.timestamp('us'), value
if kind == 'local-zone':
initial = datetime(2020, 1, 1, microsecond=123000, tzinfo=timezone.utc)
value = datetime(2020, 1, 1, 8, microsecond=micros, tzinfo=timezone(timedelta(hours=8)))
return pa.timestamp('ms', tz='UTC'), initial, pa.timestamp('us', tz='UTC'), value
return (pa.timestamp('ms'), datetime(2020, 1, 1, microsecond=123000),
pa.timestamp('us'), datetime(2020, 1, 1, microsecond=micros))
def _append(table, data):
builder = table.new_batch_write_builder()
writer = builder.new_write()
try:
writer.write_arrow(data)
builder.new_commit().commit(writer.prepare_commit())
finally:
writer.close()
def _tables(catalog, key_type, initial, native, historical):
name = 'default.temporal_row_keys'
schema = pa.schema([('key', key_type), ('value', pa.int32())])
catalog.create_table(name, Schema.from_pyarrow_schema(schema, options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
}), False)
latest = catalog.get_table(name)
_append(latest, pa.table({'key': [initial], 'value': [10]}, schema=schema))
target = latest
if historical:
catalog.alter_table(name, [SchemaChange.add_column('extra', AtomicType('INT'))])
latest = catalog.get_table(name)
_append(latest, pa.table({'key': [None], 'value': [90], 'extra': [900]},
schema=schema.append(pa.field('extra', pa.int32()))))
target = latest.copy({'scan.snapshot-id': '1'})
return latest, target.copy({'write.native.enabled': str(native).lower()})
def _rows(table):
read = table.new_read_builder().with_projection(table.field_names + ['_ROW_ID'])
return read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('_ROW_ID').to_pylist()
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('historical', [False, True], ids=['current', 'historical'])
@pytest.mark.parametrize('kind', _UNSAFE_KINDS)
def test_lossy_temporal_row_key_does_not_overwrite_existing_rows(
native_rest_catalog, native, stream, historical, kind):
key_type, initial, source_type, value = _case(kind)
latest, target = _tables(native_rest_catalog, key_type, initial, native, historical)
before = _rows(latest)
fields = Schema.from_pyarrow_schema(pa.schema([('key', source_type), ('value', pa.int32())])).fields
row = GenericRow([value, 99], fields)
builder = target.new_stream_write_builder() if stream else target.new_batch_write_builder()
original = PythonTableUpsert._upsert_row_partition
with patch.object(PythonTableUpsert, '_upsert_row_partition', autospec=True, side_effect=original) as fallback:
update = builder.new_update().with_update_type(['value'])
messages = update.upsert_by_key([row], ['key'], 17) if stream else update.upsert_by_key([row], ['key'])
fallback.assert_called_once()
assert messages and all(message.check_from_snapshot is None for message in messages)
publisher = latest.new_stream_write_builder() if stream else latest.new_batch_write_builder()
committer = publisher.new_commit()
try:
committer.commit(messages, 17) if stream else committer.commit(messages)
finally:
committer.close()
expected = {'key': pa.array([value], type=key_type)[0].as_py(), 'value': 99, '_ROW_ID': len(before)}
if historical:
expected['extra'] = None
assert _rows(latest) == before + [expected]
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('kind', ['timestamp', 'seconds', 'local-zone', 'time', 'date', 'null'])
def test_exact_temporal_row_keys_keep_native_matching(native_rest_catalog, native, kind):
key_type, initial, source_type, value = _case(kind, exact=True)
if kind == 'null':
initial = value = None
latest, target = _tables(native_rest_catalog, key_type, initial, native, False)
fields = Schema.from_pyarrow_schema(pa.schema([('key', source_type), ('value', pa.int32())])).fields
with ExitStack() as stack:
if native:
stack.enter_context(patch.object(PythonTableUpsert, '_upsert_row_partition',
side_effect=AssertionError('Exact temporal key fell back')))
messages = target.new_batch_write_builder().new_update().with_update_type(['value']).upsert_by_key(
[GenericRow([value, 99], fields)], ['key'])
assert messages and all(message.check_from_snapshot == 1 for message in messages)
latest.new_batch_write_builder().new_commit().commit(messages)
assert _rows(latest) == [{'key': initial, 'value': 99, '_ROW_ID': 0}]
@pytest.mark.parametrize('kind', _UNSAFE_KINDS + _TICK_KINDS)
def test_lossy_temporal_keys_are_rejected_before_constructing_core_updater(native_rest_catalog, kind):
key_type, initial, _, value = _case(kind)
_, target = _tables(native_rest_catalog, key_type, initial, True, False)
native_table = Mock()
with patch('pypaimon.execution.native.write.native_update.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_update.create_native_write_table', return_value=native_table):
writer = create_native_upsert(target, 'temporal-identity', [{'key': value, 'value': 99}], ['key'], ['value'])
assert writer is None
native_table.new_batch_write_builder.assert_not_called()
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('kind', _TICK_KINDS)
def test_integer_row_keys_are_not_reinterpreted_as_existing_temporal_keys(native_rest_catalog, native, kind):
key_type, initial, source_type, value = _case(kind)
latest, target = _tables(native_rest_catalog, key_type, initial, native, False)
fields = Schema.from_pyarrow_schema(pa.schema([('key', source_type), ('value', pa.int32())])).fields
original = PythonTableUpsert._upsert_row_partition
with patch.object(PythonTableUpsert, '_upsert_row_partition', autospec=True, side_effect=original) as fallback:
messages = target.new_batch_write_builder().new_update().with_update_type(['value']).upsert_by_key(
[GenericRow([value, 99], fields)], ['key'])
fallback.assert_called_once()
assert messages and all(message.check_from_snapshot is None for message in messages)
latest.new_batch_write_builder().new_commit().commit(messages)
assert _rows(latest) == [
{'key': initial, 'value': 10, '_ROW_ID': 0},
{'key': initial, 'value': 99, '_ROW_ID': 1},
]
def test_temporal_precision_check_preserves_fold_and_equivalent_instants(native_rest_catalog):
zoneinfo = pytest.importorskip('zoneinfo')
initial = datetime(2021, 11, 7, 6, 30, microsecond=123000, tzinfo=timezone.utc)
_, target = _tables(native_rest_catalog, pa.timestamp('ms', tz='UTC'), initial, True, False)
inputs = [
initial,
initial.astimezone(timezone(timedelta(hours=8))),
datetime(2021, 11, 7, 1, 30, microsecond=123000, tzinfo=zoneinfo.ZoneInfo('America/New_York'), fold=1),
initial.astimezone(timezone(timedelta(microseconds=500))),
]
for value in inputs:
native_table = Mock()
with patch('pypaimon.execution.native.write.native_update.native_runtime_available', return_value=True), \
patch(
'pypaimon.execution.native.write.native_update.create_native_write_table',
return_value=native_table):
writer = create_native_upsert(target, 'temporal-identity',
[{'key': value, 'value': 99}], ['key'], ['value'])
assert isinstance(writer, NativeTableUpsert)
native_table.new_batch_write_builder.assert_called_once()
assert writer.data[0].column('key').to_pylist() == [initial]
def test_pytz_temporal_keys_keep_the_existing_instant_conversion(native_rest_catalog):
pytz = pytest.importorskip('pytz')
initial = datetime(2021, 11, 7, 6, 30, microsecond=123000, tzinfo=timezone.utc)
value = pytz.timezone('America/New_York').localize(datetime(2021, 11, 7, 1, 30, 0, 123000), is_dst=False)
_, target = _tables(native_rest_catalog, pa.timestamp('ms', tz='UTC'), initial, True, False)
native_table = Mock()
with patch('pypaimon.execution.native.write.native_update.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_update.create_native_write_table', return_value=native_table):
writer = create_native_upsert(target, 'temporal-identity', [{'key': value, 'value': 99}], ['key'], ['value'])
assert isinstance(writer, NativeTableUpsert)
assert writer.data[0].column('key').to_pylist() == [initial]
def test_partition_temporal_keys_also_preserve_their_original_identity(native_rest_catalog):
schema = pa.schema([('key', pa.int32()), ('p', pa.timestamp('ms')), ('value', pa.int32())])
native_rest_catalog.create_table('default.temporal_partition', Schema.from_pyarrow_schema(
schema, partition_keys=['p'], options={
'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true', 'write.native.enabled': 'true',
}), False)
table = native_rest_catalog.get_table('default.temporal_partition')
native_table = Mock()
with patch('pypaimon.execution.native.write.native_update.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_update.create_native_write_table', return_value=native_table):
writer = create_native_upsert(table, 'temporal-identity',
[{'key': 1, 'p': datetime(2020, 1, 1, microsecond=123456), 'value': 99}],
['key'], ['value'])
assert writer is None
native_table.new_batch_write_builder.assert_not_called()
def test_temporal_keys_are_qualified_before_blob_payloads_in_any_row_shape():
schema = pa.schema([('key', pa.timestamp('ms')), ('payload', pa.large_binary()), ('keep', pa.int32())])
fields = Schema.from_pyarrow_schema(schema).fields
rows = [
{'key': datetime(2020, 1, 1, microsecond=123000), 'payload': b'first'},
{'key': datetime(2020, 1, 1, microsecond=123456), 'payload': b'second', 'keep': None},
]
blobs = Mock()
blobs.to_batch.side_effect = AssertionError('Payload was consumed before temporal key qualification')
assert _upsert_row_batches(rows, fields, schema, blob_rows=blobs, key_names=['key']) is None
blobs.to_batch.assert_not_called()