blob: 95013eda28413c3ada0a2d7bf47efce059cfddef [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.
"""Nanosecond timestamp keys use Java's BinaryRow layout and bucket IDs."""
import struct
from contextlib import ExitStack
from datetime import datetime, timezone
import pandas as pd
import pyarrow as pa
import pyarrow.dataset as ds
import pytest
from unittest.mock import patch
from pypaimon import Schema
from pypaimon.common.predicate import Predicate
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer, GenericRowSerializer
from pypaimon.tests import native_plan_rest_test
from pypaimon.write.write_dispatch import NativeTableWrite
from pypaimon.execution.python.write.row_key_extractor import FixedBucketRowKeyExtractor
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
rest_catalog = native_plan_rest_test.rest_catalog
_ENGINES = [False, pytest.param(True, marks=[pytest.mark.native_plan, pytest.mark.skipif(
not native_runtime_available(), reason='pypaimon-rust not installed')])]
# Java BinaryRowWriter stores millis in the variable area and nanoOfMillisecond
# in the low half of the fixed slot. These are default-function buckets mod 5.
_BUCKETS = {-1000001: 3, -1000000: 3, -1: 0, 0: 4, 1: 2, 2: 3,
999999: 2, 1000000: 4, 1000001: 0, 1000002: 3}
_BOUNDARY_BUCKETS = {-9223372036854775807: 4, -9223372036854775001: 0, 9223372036854775807: 4}
def _bucket_cases(zone):
# The ns endpoints cannot be represented by pandas in zones that shift
# their displayed local time outside its range. UTC is a legal LTZ input.
buckets = _BUCKETS.copy()
if zone in (None, 'UTC'):
buckets.update(_BOUNDARY_BUCKETS)
return buckets
def _value(ticks, zone):
value = pd.Timestamp(ticks, unit='ns', tz='UTC' if zone is not None else None)
return value.tz_convert(zone) if zone is not None else value
@pytest.mark.parametrize('zone', [None, 'UTC', 'America/New_York', 'Asia/Shanghai'])
@pytest.mark.parametrize('precision', [3, 9], ids=['compact', 'noncompact'])
def test_timestamp_key_bytes_match_java_for_nanosecond_and_negative_epoch_values(zone, precision):
name = 'TIMESTAMP_LTZ' if zone is not None else 'TIMESTAMP'
field = DataField(0, 'key', AtomicType('%s(%d)' % (name, precision), nullable=False))
extractor = FixedBucketRowKeyExtractor(TableSchema(
fields=[field], primary_keys=['key'], options={'bucket': '5'}))
for ticks, bucket in _bucket_cases(zone).items():
value = _value(ticks, zone)
millis, nanos = divmod(ticks, 1000000)
if precision == 3:
expected = struct.pack('>i', 1) + struct.pack('<qq', 0, millis)
else:
expected = struct.pack('>i', 1) + struct.pack('<qQq', 0, (16 << 32) | nanos, millis)
assert extractor.extract_partition_bucket_row({'key': value})[1] == bucket
assert GenericRowSerializer.to_bytes(GenericRow([value], [field])) == expected
@pytest.mark.parametrize('zone', [None, 'UTC', 'America/New_York', 'Asia/Shanghai'])
def test_timestamp_binary_row_stats_deserialize_without_truncating_nanoseconds(zone):
name = 'TIMESTAMP_LTZ' if zone is not None else 'TIMESTAMP'
fields = [DataField(0, 'key', AtomicType(name + '(9)', nullable=False))]
for ticks in _bucket_cases(zone):
millis, nanos = divmod(ticks, 1000000)
encoded = struct.pack('>i', 1) + struct.pack('<qQq', 0, (16 << 32) | nanos, millis)
result = GenericRowDeserializer.from_bytes(encoded, fields).values[0]
assert result == _value(ticks, zone)
assert GenericRowSerializer.to_bytes(GenericRow([result], fields)) == encoded
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
@pytest.mark.parametrize('method', ['in', 'notIn'])
@pytest.mark.parametrize('with_null', [False, True])
def test_nanosecond_membership_literals_preserve_sql_null_semantics(zone, method, with_null):
data_type = pa.timestamp('ns', tz='UTC' if zone is not None else None)
data = pa.table({'key': pa.array([-1, 0, 1, 2, None], pa.int64()).cast(data_type)})
values = [_value(1, zone)] + ([None] if with_null else [])
predicate = Predicate(method=method, field='key', index=0, literals=values)
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
expected = [1] if method == 'in' else [] if with_null else [-1, 0, 2]
assert result['key'].cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
@pytest.mark.parametrize('method', ['in', 'notIn'])
@pytest.mark.parametrize('plain_datetime', [False, True], ids=['pandas', 'datetime'])
@pytest.mark.parametrize('with_null', [False, True])
def test_nanosecond_membership_accepts_mixed_timestamp_resolutions(zone, method, plain_datetime, with_null):
data_type = pa.timestamp('ns', tz='UTC' if zone is not None else None)
data = pa.table({'key': pa.array([-1, 0, 1, 2, None], pa.int64()).cast(data_type)})
zero = (datetime(1970, 1, 1, tzinfo=timezone.utc if zone is not None else None)
if plain_datetime else _value(0, zone))
values = [_value(1, zone), zero] + ([None] if with_null else [])
predicate = Predicate(method=method, field='key', index=0, literals=values)
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
expected = [0, 1] if method == 'in' else [] if with_null else [-1, 2]
assert result['key'].cast(pa.int64()).to_pylist() == expected
assert all(not isinstance(value, pa.Scalar) for value in predicate.literals)
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
@pytest.mark.parametrize('method', ['between', 'notBetween'])
def test_nanosecond_ranges_accept_zero_tail_literals_in_another_timezone(zone, method):
data_type = pa.timestamp('ns', tz='UTC' if zone is not None else None)
data = pa.table({'key': pa.array([-1, 0, 1, 2, None], pa.int64()).cast(data_type)})
predicate = Predicate(method=method, field='key', index=0, literals=[_value(0, zone), _value(1, zone)])
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
expected = [0, 1] if method == 'between' else [-1, 2]
assert result['key'].cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('zone', [None, 'UTC'])
@pytest.mark.parametrize('method', ['equal', 'in', 'between'])
def test_nanosecond_literal_normalization_keeps_timezone_awareness_validation(zone, method):
data_type = pa.timestamp('ns', tz=zone)
data = pa.table({'key': pa.array([0, 1, 2], pa.int64()).cast(data_type)})
values = [_value(1, 'UTC' if zone is None else None), _value(2, zone)]
predicate = Predicate(method=method, field='key', index=0, literals=values)
with pytest.raises((pa.ArrowException, TypeError)):
ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
@pytest.mark.parametrize('zone', [None, 'UTC'])
@pytest.mark.parametrize('method', ['equal', 'in'])
def test_ordinary_datetime_literals_keep_their_wider_microsecond_range(zone, method):
tzinfo = timezone.utc if zone is not None else None
values = [datetime(1, 1, 1, tzinfo=tzinfo), datetime(9999, 12, 31, tzinfo=tzinfo)]
data = pa.table({'key': values}, schema=pa.schema([('key', pa.timestamp('us', tz=zone))]))
predicate = Predicate(method=method, field='key', index=0, literals=values)
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
assert result['key'].to_pylist() == (values[:1] if method == 'equal' else values)
@pytest.mark.parametrize('unit', ['ms', 'us'])
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
@pytest.mark.parametrize('ticks', [-1, 1])
def test_nanosecond_comparisons_do_not_overflow_wider_timestamp_columns(unit, zone, ticks):
tzinfo = timezone.utc if zone is not None else None
values = [datetime(1, 1, 1, tzinfo=tzinfo), datetime(1970, 1, 1, tzinfo=tzinfo),
datetime(9999, 12, 31, tzinfo=tzinfo), None]
data = pa.table({'key': values}, schema=pa.schema([('key', pa.timestamp(unit, tz='UTC' if zone else None))]))
literal = _value(ticks, zone)
expected = {
'equal': [], 'notEqual': values[:3],
'lessThan': values[:2] if ticks > 0 else values[:1],
'lessOrEqual': values[:2] if ticks > 0 else values[:1],
'greaterThan': values[2:3] if ticks > 0 else values[1:3],
'greaterOrEqual': values[2:3] if ticks > 0 else values[1:3],
}
for method, rows in expected.items():
predicate = Predicate(method=method, field='key', index=0, literals=[literal])
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
assert result['key'].to_pylist() == rows, method
@pytest.mark.parametrize('unit', ['ms', 'us', 'ns'])
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
@pytest.mark.parametrize('with_null', [False, True])
def test_nanosecond_sets_and_ranges_accept_wide_companion_literals(unit, zone, with_null):
data_type = pa.timestamp(unit, tz='UTC' if zone is not None else None)
data = pa.table({'key': pa.array([-1, 0, 1, None], pa.int64()).cast(data_type)})
far = datetime(3000, 1, 1, tzinfo=timezone.utc if zone is not None else None)
values = [_value(1, zone), far] + ([None] if with_null else [])
for method in ['in', 'notIn']:
predicate = Predicate(method=method, field='key', index=0, literals=values)
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
expected = ([1] if unit == 'ns' else []) if method == 'in' else (
[] if with_null else [-1, 0] if unit == 'ns' else [-1, 0, 1])
assert result['key'].cast(pa.int64()).to_pylist() == expected, method
for method, expected in [('between', [1]), ('notBetween', [-1, 0])]:
predicate = Predicate(method=method, field='key', index=0, literals=values[:2])
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
assert result['key'].cast(pa.int64()).to_pylist() == expected, method
def test_nanosecond_literals_filter_wide_millisecond_parquet_rows(rest_catalog):
catalog, _ = rest_catalog
schema = pa.schema([('key', pa.timestamp('ms')), ('value', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options={
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
far = datetime(3000, 1, 1)
try:
writer.write_arrow(pa.table({'key': [far, datetime(1970, 1, 1), None], 'value': [0, 1, 2]}, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
nano = _value(1, None)
cases = [('equal', [nano], []), ('lessThan', [nano], [1]), ('greaterThan', [nano], [0]),
('in', [nano, far], [0]), ('notIn', [nano, far], [1]), ('notIn', [nano, far, None], []),
('between', [_value(-1, None), far], [0, 1])]
for method, literals, expected in cases:
read = table.new_read_builder()
read.with_filter(Predicate(method=method, field='key', index=0, literals=literals))
result = read.new_read().to_arrow(read.new_scan().plan().splits())
assert sorted(result['value'].to_pylist()) == expected, method
@pytest.mark.parametrize('unit,multiplier', [('ms', 1000000), ('us', 1000)])
@pytest.mark.parametrize('zone', [None, 'America/New_York'])
def test_nanosecond_literals_are_not_truncated_to_a_lower_precision_field(unit, multiplier, zone):
data_type = pa.timestamp(unit, tz='UTC' if zone is not None else None)
data = pa.table({'key': pa.array([-1, 0, 1], pa.int64()).cast(data_type)})
for method, expected in [('equal', []), ('lessThan', [-multiplier, 0]), ('greaterThan', [multiplier])]:
predicate = Predicate(method=method, field='key', index=0, literals=[_value(1, zone)])
result = ds.dataset(data).to_table(filter=predicate.to_arrow(data.schema))
assert result['key'].cast(pa.timestamp('ns', tz=data_type.tz)).cast(pa.int64()).to_pylist() == expected
@pytest.mark.parametrize('native', _ENGINES, ids=['python', 'native'])
@pytest.mark.parametrize('row_input', [False, True], ids=['arrow', 'row'])
@pytest.mark.parametrize('zone', [None, 'UTC', 'America/New_York', 'Asia/Shanghai'])
def test_timestamp_row_and_sliced_arrow_writes_agree_with_precomputed_java_buckets(
rest_catalog, native, row_input, zone):
catalog, _ = rest_catalog
schema = pa.schema([('key', pa.timestamp('ns', tz='UTC' if zone is not None else None)),
('value', pa.string())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, primary_keys=['key'], options={
'bucket': '5', 'write.native.enabled': str(native).lower(), 'commit.native.enabled': str(native).lower(),
'read.native.enabled': str(native).lower(), 'scan.native-plan.enabled': str(native).lower()}), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
buckets = _bucket_cases(zone)
values = [_value(ticks, zone) for ticks in buckets]
try:
assert isinstance(writer, NativeTableWrite) == native
with ExitStack() as guards:
if native:
guards.enter_context(patch.object(writer, '_switch_to_python', side_effect=AssertionError('fallback')))
guards.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.commit',
side_effect=AssertionError('Python commit fallback')))
if row_input:
for value in values:
writer.write_row(GenericRow([value, 'original'], table.fields))
else:
data = pa.table({'key': [_value(42, zone)] + values + [_value(47, zone)],
'value': ['unwritten'] + ['original'] * len(values) + ['unwritten']}, schema=schema)
writer.write_arrow(data.slice(1, len(values)))
writer.write_arrow(pa.table({'key': [_value(1, zone)], 'value': ['updated']}, schema=schema), bucket=2)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
with ExitStack() as guards:
if native:
guards.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')))
read = table.new_read_builder()
rows = {}
for split in read.new_scan().plan().splits():
result = read.new_read().to_arrow([split])
for ticks, value in zip(result['key'].cast(pa.int64()).to_pylist(), result['value'].to_pylist()):
assert buckets[ticks] == split.bucket
assert ticks not in rows, 'One timestamp key was written to different LSM buckets'
rows[ticks] = value
assert rows == {ticks: 'updated' if ticks == 1 else 'original' for ticks in buckets}
for ticks in [-1, 1, 2, 1000001]:
filtered = table.new_read_builder()
filtered.with_filter(filtered.new_predicate_builder().equal('key', _value(ticks, zone)))
result = filtered.new_read().to_arrow(filtered.new_scan().plan().splits())
assert result['key'].cast(pa.int64()).to_pylist() == [ticks]
filtered = table.new_read_builder()
predicate = filtered.new_predicate_builder()
filtered.with_filter(predicate.and_predicates([
predicate.greater_or_equal('key', _value(-1, zone)), predicate.less_than('key', _value(2, zone))]))
result = filtered.new_read().to_arrow(filtered.new_scan().plan().splits())
assert sorted(result['key'].cast(pa.int64()).to_pylist()) == [-1, 0, 1]