blob: 4eeea379d0dbb67093e3ab4a85a9c31cd1a548a4 [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.
"""The two streaming executors implement the same state and lifecycle API."""
import asyncio
from concurrent.futures import Future, ThreadPoolExecutor
import json
import subprocess
import sys
import threading
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.read.plan import Plan
pytestmark = [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'),
])]
def test_streaming_executors_declare_the_same_control_protocol():
# CI instruments Native plan() with a variadic wrapper. Inspect the real
# declarations in a fresh interpreter rather than that test-only wrapper.
script = """
import inspect
from pypaimon.execution.native.read.streaming_table_scan import StreamTableScan
from pypaimon.execution.python.read.streaming_table_scan import AsyncStreamingTableScan
for name in ('plan', 'checkpoint', 'restore', 'watermark',
'notify_checkpoint_complete', 'close', 'stream', 'stream_sync'):
assert inspect.signature(getattr(AsyncStreamingTableScan, name)) == inspect.signature(
getattr(StreamTableScan, name)), name
"""
result = subprocess.run([sys.executable, '-c', script], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, universal_newlines=True)
assert result.returncode == 0, result.stdout
def _table(tmp_path, native):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('db', True)
catalog.create_table('db.t', Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32()), ('value', pa.int32())]), primary_keys=['id'], options={
'bucket': '1', 'changelog-producer': 'input',
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': str(native).lower(),
}), False)
return catalog.get_table('db.t')
def _write(table, value, watermark=None):
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.table({'id': pa.array([1], pa.int32()),
'value': pa.array([value], pa.int32())}))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
manager = table.snapshot_manager()
snapshot = manager.get_latest_snapshot()
if watermark is not None:
path = manager.get_snapshot_path(snapshot.id)
metadata = json.loads(table.file_io.read_file_utf8(path))
metadata['watermark'] = watermark
table.file_io.overwrite_file_utf8(path, json.dumps(metadata))
return snapshot.id
@pytest.mark.parametrize('native', _ENGINES)
def test_control_methods_share_checkpoints_watermarks_and_explicit_ack(tmp_path, native):
table = _table(tmp_path, native)
_write(table, 10, watermark=100)
_write(table, 20, watermark=200)
scan = table.new_stream_read_builder().with_consumer_id('job').new_streaming_scan()
try:
assert scan.checkpoint() is None
assert scan.watermark() is None
assert scan.plan().snapshot_id == 2
assert scan.checkpoint() == scan.next_snapshot_id == 3
assert scan.watermark() == 200
assert table.consumer_manager().consumer('job') is None
scan.notify_checkpoint_complete(scan.checkpoint())
assert table.consumer_manager().consumer('job').next_snapshot == 3
scan.restore(1)
assert scan.plan().snapshot_id == 1
assert scan.checkpoint() == 2
assert scan.watermark() == 100
scan.next_snapshot_id = 2
assert scan.plan().snapshot_id == 2
assert scan.plan() is None
scan.restore()
assert scan.plan() is None # Restores the acknowledged consumer, not the initial full state.
assert scan.checkpoint() == 3
with pytest.raises(ValueError, match='positive'):
scan.restore(0)
with pytest.raises(ValueError, match='positive'):
scan.notify_checkpoint_complete(0)
with pytest.raises(OverflowError):
scan.restore(1 << 63)
with pytest.raises(TypeError):
scan.restore(1.5)
finally:
scan.close()
scan.close()
with pytest.raises(RuntimeError, match='closed'):
scan.plan()
with pytest.raises(RuntimeError, match='closed'):
scan.restore(1)
@pytest.mark.parametrize('native', _ENGINES)
def test_large_backlog_keeps_each_physical_changelog_frame(tmp_path, native):
table = _table(tmp_path, native)
for value in range(15):
_write(table, value)
scan = table.new_stream_read_builder().new_streaming_scan()
try:
scan.restore(1)
assert [scan.plan().snapshot_id for _ in range(15)] == list(range(1, 16))
assert scan.checkpoint() == 16
assert scan.plan() is None
finally:
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
def test_empty_follow_up_is_not_emitted_and_retains_retryable_progress(tmp_path, native):
table = _table(tmp_path, native)
for value in range(3):
_write(table, value, watermark=value * 10)
scan = (table.new_stream_read_builder().with_consumer_id('empty')
.with_bucket_filter(lambda _: False).new_streaming_scan())
try:
scan.restore(1)
assert scan.plan() is None
assert scan.checkpoint() == 4
assert scan.watermark() == 20
assert table.consumer_manager().consumer('empty') is None
scan.notify_checkpoint_complete(4)
assert table.consumer_manager().consumer('empty').next_snapshot == 4
finally:
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
def test_cancelled_yield_is_not_acknowledged_by_a_new_iterator(tmp_path, native):
table = _table(tmp_path, native)
_write(table, 10)
scan = (table.new_stream_read_builder().with_consumer_id('cancelled')
.with_poll_interval_ms(1).new_streaming_scan())
async def scenario():
iterator = scan.stream()
assert (await iterator.__anext__()).snapshot_id == 1
assert scan._pending_consumer_snapshot == 2
await iterator.aclose()
assert scan._pending_consumer_snapshot is None
resumed = scan.stream()
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(resumed.__anext__(), timeout=0.02)
assert table.consumer_manager().consumer('cancelled') is None
finally:
await resumed.aclose()
try:
asyncio.run(scenario())
finally:
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
def test_old_iterator_close_does_not_clear_a_new_iterator_pending_frame(tmp_path, native):
table = _table(tmp_path, native)
_write(table, 10)
scan = table.new_stream_read_builder().with_consumer_id('owners').new_streaming_scan()
async def scenario():
old = scan.stream()
current = scan.stream()
try:
assert (await old.__anext__()).snapshot_id == 1
_write(table, 20)
assert (await current.__anext__()).snapshot_id == 2
assert table.consumer_manager().consumer('owners').next_snapshot == 2
await old.aclose()
assert scan._pending_consumer_snapshot == 3
await current.aclose()
assert scan._pending_consumer_snapshot is None
assert table.consumer_manager().consumer('owners').next_snapshot == 2
finally:
await old.aclose()
await current.aclose()
try:
asyncio.run(scenario())
finally:
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
def test_sync_iterator_close_discards_unacknowledged_frame(tmp_path, native):
table = _table(tmp_path, native)
_write(table, 10)
scan = table.new_stream_read_builder().with_consumer_id('sync-close').new_streaming_scan()
iterator = scan.stream_sync()
try:
assert next(iterator).snapshot_id == 1
assert scan._pending_consumer_snapshot == 2
iterator.close()
assert scan._pending_consumer_snapshot is None
assert table.consumer_manager().consumer('sync-close') is None
finally:
iterator.close()
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
def test_ack_failure_keeps_pending_checkpoint_for_retry(tmp_path, native):
table = _table(tmp_path, native)
_write(table, 10)
scan = (table.new_stream_read_builder().with_consumer_id('ack-retry')
.with_poll_interval_ms(1).new_streaming_scan())
async def scenario():
iterator = scan.stream()
assert (await iterator.__anext__()).snapshot_id == 1
failure = OSError('consumer storage unavailable')
with patch.object(scan, 'notify_checkpoint_complete', side_effect=failure):
with pytest.raises(OSError) as caught:
await iterator.__anext__()
assert caught.value is failure
assert scan._pending_consumer_snapshot == 2
assert table.consumer_manager().consumer('ack-retry') is None
retry = scan.stream()
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(retry.__anext__(), timeout=0.02)
assert table.consumer_manager().consumer('ack-retry').next_snapshot == 2
finally:
await retry.aclose()
await iterator.aclose()
try:
asyncio.run(scenario())
finally:
scan.close()
def test_prefetch_failure_is_not_hidden_or_advanced(tmp_path):
table = _table(tmp_path, False)
_write(table, 10)
scan = table.new_stream_read_builder().new_streaming_scan()
scan.restore(1)
future = Future()
failure = OSError('prefetch manifest unavailable')
future.set_exception(failure)
scan._execution._prefetch_future = future
try:
with pytest.raises(OSError) as caught:
scan.plan()
assert caught.value is failure
assert scan.checkpoint() == 1
assert scan.plan().snapshot_id == 1
assert scan.checkpoint() == 2
finally:
scan.close()
def test_restoring_during_prefetch_does_not_apply_its_stale_result(tmp_path):
table = _table(tmp_path, False)
scan = table.new_stream_read_builder().new_streaming_scan()
scan.restore(1)
future = Future()
started = threading.Event()
original_result = future.result
def result():
started.set()
return original_result(timeout=5)
future.result = result
scan._execution._prefetch_future = future
try:
with ThreadPoolExecutor(max_workers=1) as executor:
planning = executor.submit(scan.plan)
assert started.wait(5)
scan.restore(7)
future.set_result((Plan([object()], snapshot_id=1), 2, 0, 100))
with pytest.raises(RuntimeError, match='restored during planning'):
planning.result(timeout=5)
assert scan.checkpoint() == 7
assert scan.watermark() is None
assert scan._execution._prefetch_future is None
finally:
scan.close()
def test_native_plan_conversion_failure_retries_the_same_frame():
from pypaimon.execution.native.read.streaming_table_scan import StreamTableScan
class CoreScan:
checkpoint_id = 1
last_watermark = None
def checkpoint(self):
return self.checkpoint_id
def restore(self, checkpoint):
self.checkpoint_id = checkpoint
def watermark(self):
return self.last_watermark
def plan(self):
assert self.checkpoint_id == 1
self.checkpoint_id = 2
self.last_watermark = 100
return object()
with patch('pypaimon.execution.native.read.streaming_table_scan.native_stream_scan', return_value=CoreScan()):
scan = StreamTableScan(object())
plan = Plan([object()], snapshot_id=1)
try:
with patch('pypaimon.execution.native.read.streaming_table_scan._from_native_plan',
side_effect=[ValueError('bad split'), plan]):
with pytest.raises(ValueError, match='bad split'):
scan.plan()
assert scan.checkpoint() == 1
assert scan.watermark() is None
assert scan.plan() is plan
assert scan.checkpoint() == 2
assert scan.watermark() == 100
finally:
scan.close()
@pytest.mark.parametrize('old_watermark', [None, 50])
@pytest.mark.parametrize('next_poll', ['wait', 'irrelevant', 'empty'])
def test_failed_decode_restores_watermark_before_later_none_or_empty_poll(old_watermark, next_poll):
from pypaimon.execution.native.read.streaming_table_scan import StreamTableScan
class CoreScan:
def __init__(self):
self.next_id = 1
self.last_watermark = old_watermark
def checkpoint(self):
return self.next_id
def watermark(self):
return self.last_watermark
def restore(self, checkpoint):
self.next_id = checkpoint
def plan(self):
if self.next_id == 1:
self.next_id = 2
self.last_watermark = 100
return object()
if next_poll != 'wait':
self.next_id = 3
if next_poll == 'empty':
# A legitimate empty scannable frame has exactly the same
# watermark as the previously failed conversion.
self.last_watermark = 100
return None
with patch('pypaimon.execution.native.read.streaming_table_scan.native_stream_scan', return_value=CoreScan()):
scan = StreamTableScan(object())
try:
with patch('pypaimon.execution.native.read.streaming_table_scan._from_native_plan',
side_effect=ValueError('bad split')):
with pytest.raises(ValueError, match='bad split'):
scan.plan()
assert scan.checkpoint() == 1
assert scan.watermark() == old_watermark
scan.restore(2)
assert scan.plan() is None
assert scan.watermark() == (100 if next_poll == 'empty' else old_watermark)
assert scan.checkpoint() == (2 if next_poll == 'wait' else 3)
finally:
scan.close()
@pytest.mark.parametrize('native', _ENGINES)
@pytest.mark.parametrize('case', ['expired', 'future', 'empty', 'next'])
def test_missing_checkpoint_periodically_distinguishes_publication_from_expiration(tmp_path, native, case):
table = _table(tmp_path, native)
if case != 'empty':
_write(table, 10)
_write(table, 20)
manager = table.snapshot_manager()
if case == 'expired':
table.file_io.delete(manager.get_snapshot_path(1))
table.file_io.overwrite_file_utf8(manager.snapshot_dir + '/EARLIEST', '2')
checkpoint = 1 if case == 'expired' else 3 if case == 'next' else 4
scan = table.new_stream_read_builder().new_streaming_scan()
try:
scan.restore(checkpoint)
for _ in range(15):
assert scan.plan() is None
if case == 'next':
assert scan.plan() is None
_write(table, 30)
assert scan.plan().snapshot_id == 3
assert scan.checkpoint() == 4
# A selected frame resets the missing-snapshot counter.
for _ in range(16):
assert scan.plan() is None
else:
with pytest.raises(ValueError, match='outside the available snapshots'):
scan.plan()
scan.restore(4 if case == 'next' else checkpoint)
assert scan.plan() is None # Restore also resets the periodic check.
finally:
scan.close()
@pytest.mark.parametrize('result', [None, object()])
def test_native_close_during_planning_does_not_return_or_restore_a_frame(result):
from pypaimon.execution.native.read.streaming_table_scan import StreamTableScan
entered, release = threading.Event(), threading.Event()
class CoreScan:
restored = []
def checkpoint(self):
return 1
def plan(self):
entered.set()
assert release.wait(5)
return result
def restore(self, checkpoint):
self.restored.append(checkpoint)
core = CoreScan()
with patch('pypaimon.execution.native.read.streaming_table_scan.native_stream_scan', return_value=core):
scan = StreamTableScan(object())
try:
with ThreadPoolExecutor(max_workers=1) as executor:
planning = executor.submit(scan.plan)
assert entered.wait(5)
scan.close()
release.set()
with pytest.raises(RuntimeError, match='closed'):
planning.result(timeout=5)
assert core.restored == []
finally:
release.set()
scan.close()