| # 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() |