| # 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. | |
| import os | |
| import shutil | |
| import tempfile | |
| import unittest | |
| import pyarrow as pa | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER | |
| from pypaimon.write.commit_callback import CommitCallback, CommitCallbackContext | |
| class RecordingCallback(CommitCallback): | |
| """Test callback that records all invocations.""" | |
| def __init__(self): | |
| self.contexts = [] | |
| self.closed = False | |
| def call(self, context: CommitCallbackContext) -> None: | |
| self.contexts.append(context) | |
| def close(self) -> None: | |
| self.closed = True | |
| class CommitCallbackTest(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| cls.tempdir = tempfile.mkdtemp() | |
| cls.warehouse = os.path.join(cls.tempdir, 'warehouse') | |
| cls.catalog = CatalogFactory.create({'warehouse': cls.warehouse}) | |
| cls.catalog.create_database('default', True) | |
| cls.pa_schema = pa.schema([ | |
| ('id', pa.int32()), | |
| ('name', pa.string()), | |
| ('dt', pa.string()), | |
| ]) | |
| @classmethod | |
| def tearDownClass(cls): | |
| shutil.rmtree(cls.tempdir, ignore_errors=True) | |
| def _create_table(self, table_name, partition_keys=None, options=None): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=partition_keys or [], | |
| options=options or {}) | |
| self.catalog.create_table(f'default.{table_name}', schema, False) | |
| return self.catalog.get_table(f'default.{table_name}') | |
| def _lose_commit_response_once(self, table_commit): | |
| real_commit = table_commit.file_store_commit.snapshot_commit.commit | |
| attempts = [] | |
| def commit_then_lose_response( | |
| base_snapshot_uuid, snapshot, statistics): | |
| attempts.append(snapshot.id) | |
| self.assertTrue(real_commit( | |
| base_snapshot_uuid, snapshot, statistics)) | |
| raise TimeoutError('lost snapshot commit response') | |
| table_commit.file_store_commit.snapshot_commit.commit = ( | |
| commit_then_lose_response) | |
| table_commit.file_store_commit._commit_retry_wait = lambda _: None | |
| return attempts | |
| def test_callback_invoked_on_commit(self): | |
| table = self._create_table('test_callback_invoked') | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| data = pa.Table.from_pydict({ | |
| 'id': [1, 2], | |
| 'name': ['a', 'b'], | |
| 'dt': ['p1', 'p1'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit()) | |
| self.assertEqual(1, len(callback.contexts)) | |
| ctx = callback.contexts[0] | |
| self.assertEqual(1, ctx.snapshot.id) | |
| self.assertEqual('APPEND', ctx.snapshot.commit_kind) | |
| self.assertGreater(len(ctx.commit_entries), 0) | |
| table_write.close() | |
| table_commit.close() | |
| def test_callback_receives_correct_snapshot_data(self): | |
| table = self._create_table('test_callback_snapshot_data', partition_keys=['dt']) | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| data = pa.Table.from_pydict({ | |
| 'id': [1, 2, 3], | |
| 'name': ['a', 'b', 'c'], | |
| 'dt': ['p1', 'p1', 'p2'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit()) | |
| ctx = callback.contexts[0] | |
| self.assertEqual(3, ctx.snapshot.delta_record_count) | |
| self.assertEqual(3, ctx.snapshot.total_record_count) | |
| table_write.close() | |
| table_commit.close() | |
| def test_callback_invoked_after_lost_commit_response(self): | |
| table = self._create_table( | |
| 'test_callback_response_loss', | |
| options={ | |
| 'row-tracking.enabled': 'true', | |
| 'data-evolution.enabled': 'true', | |
| }, | |
| ) | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| attempts = self._lose_commit_response_once(table_commit) | |
| table_write.write_arrow(pa.Table.from_pydict({ | |
| 'id': [1, 2], | |
| 'name': ['a', 'b'], | |
| 'dt': ['p1', 'p1'], | |
| }, schema=self.pa_schema)) | |
| messages = table_write.prepare_commit() | |
| expected_paths = sorted( | |
| file.file_path | |
| for message in messages | |
| for file in message.new_files | |
| ) | |
| table_commit.commit(messages) | |
| self.assertEqual([1], attempts) | |
| self.assertEqual(1, len(callback.contexts)) | |
| self.assertEqual(1, callback.contexts[0].snapshot.id) | |
| self.assertGreater(len(callback.contexts[0].commit_entries), 0) | |
| for entry in callback.contexts[0].commit_entries: | |
| self.assertIsNotNone(entry.file.first_row_id) | |
| self.assertEqual(expected_paths, sorted( | |
| entry.file.file_path | |
| for entry in callback.contexts[0].commit_entries | |
| if entry.kind == 0 | |
| )) | |
| table_write.close() | |
| table_commit.close() | |
| def test_empty_overwrite_callback_after_lost_commit_response(self): | |
| table = self._create_table('test_empty_overwrite_response_loss') | |
| builder = table.new_batch_write_builder() | |
| table_write = builder.new_write() | |
| initial_commit = builder.new_commit() | |
| table_write.write_arrow(pa.Table.from_pydict({ | |
| 'id': [1], | |
| 'name': ['a'], | |
| 'dt': ['p1'], | |
| }, schema=self.pa_schema)) | |
| initial_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| initial_commit.close() | |
| table_commit = table.new_batch_write_builder().new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| attempts = self._lose_commit_response_once(table_commit) | |
| table_commit.file_store_commit.truncate_table( | |
| BATCH_COMMIT_IDENTIFIER) | |
| self.assertEqual([2], attempts) | |
| self.assertEqual(1, len(callback.contexts)) | |
| self.assertEqual(2, callback.contexts[0].snapshot.id) | |
| read_builder = table.new_read_builder() | |
| splits = read_builder.new_scan().plan().splits() | |
| self.assertEqual(0, read_builder.new_read().to_arrow(splits).num_rows) | |
| table_commit.close() | |
| def test_multiple_callbacks(self): | |
| table = self._create_table('test_multi_callbacks') | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| cb1 = RecordingCallback() | |
| cb2 = RecordingCallback() | |
| table_commit.add_commit_callback(cb1) | |
| table_commit.add_commit_callback(cb2) | |
| data = pa.Table.from_pydict({ | |
| 'id': [1], | |
| 'name': ['a'], | |
| 'dt': ['p1'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit()) | |
| self.assertEqual(1, len(cb1.contexts)) | |
| self.assertEqual(1, len(cb2.contexts)) | |
| self.assertEqual(cb1.contexts[0].snapshot.id, cb2.contexts[0].snapshot.id) | |
| table_write.close() | |
| table_commit.close() | |
| def test_callback_close_on_commit_close(self): | |
| table = self._create_table('test_callback_close') | |
| write_builder = table.new_batch_write_builder() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| self.assertFalse(callback.closed) | |
| table_commit.close() | |
| self.assertTrue(callback.closed) | |
| def test_callback_error_after_commit_keeps_committed_files(self): | |
| table = self._create_table('test_callback_error_after_commit') | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback_error = RuntimeError('callback failed after commit') | |
| class FailingCallback(CommitCallback): | |
| def call(self, context: CommitCallbackContext) -> None: | |
| raise callback_error | |
| table_commit.add_commit_callback(FailingCallback()) | |
| try: | |
| table_write.write_arrow(pa.Table.from_pydict({ | |
| 'id': [1], | |
| 'name': ['committed'], | |
| 'dt': ['p1'], | |
| }, schema=self.pa_schema)) | |
| messages = table_write.prepare_commit() | |
| data_paths = [ | |
| file.external_path or file.file_path | |
| for message in messages | |
| for file in message.new_files | |
| ] | |
| with self.assertRaises(RuntimeError) as context: | |
| table_commit.commit(messages) | |
| latest_snapshot = table.snapshot_manager().get_latest_snapshot() | |
| self.assertEqual(1, latest_snapshot.id) | |
| self.assertTrue(all( | |
| table.file_io.exists(path) for path in data_paths)) | |
| read_builder = table.new_read_builder() | |
| actual = read_builder.new_read().to_arrow( | |
| read_builder.new_scan().plan().splits()) | |
| self.assertEqual( | |
| {'id': [1], 'name': ['committed'], 'dt': ['p1']}, | |
| actual.to_pydict(), | |
| ) | |
| self.assertIs(callback_error, context.exception) | |
| finally: | |
| table_write.close() | |
| table_commit.close() | |
| def test_callback_not_invoked_when_no_data(self): | |
| table = self._create_table('test_callback_no_data') | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| table_commit.commit(table_write.prepare_commit()) | |
| self.assertEqual(0, len(callback.contexts)) | |
| table_write.close() | |
| table_commit.close() | |
| def test_stream_commit_callback_multiple_rounds(self): | |
| table = self._create_table('test_stream_callback') | |
| write_builder = table.new_stream_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| for i in range(3): | |
| data = pa.Table.from_pydict({ | |
| 'id': [i], | |
| 'name': [f'name_{i}'], | |
| 'dt': ['p1'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit(i), commit_identifier=i) | |
| self.assertEqual(3, len(callback.contexts)) | |
| for i, ctx in enumerate(callback.contexts): | |
| self.assertEqual(i + 1, ctx.snapshot.id) | |
| table_write.close() | |
| table_commit.close() | |
| def test_data_evolution_callback_sees_row_id(self): | |
| table = self._create_table('test_de_row_id', options={ | |
| 'row-tracking.enabled': 'true', | |
| 'data-evolution.enabled': 'true', | |
| }) | |
| write_builder = table.new_batch_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| data = pa.Table.from_pydict({ | |
| 'id': [1, 2, 3], | |
| 'name': ['a', 'b', 'c'], | |
| 'dt': ['p1', 'p2', 'p3'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit()) | |
| ctx = callback.contexts[0] | |
| self.assertIsNotNone(ctx.snapshot.next_row_id) | |
| for entry in ctx.commit_entries: | |
| self.assertIsNotNone(entry.file.first_row_id) | |
| total_rows = sum(e.file.row_count for e in ctx.commit_entries) | |
| self.assertEqual(3, total_rows) | |
| self.assertEqual(total_rows, ctx.snapshot.next_row_id) | |
| table_write.close() | |
| table_commit.close() | |
| def test_data_evolution_callback_row_id_increments_across_commits(self): | |
| table = self._create_table('test_de_row_id_incr', options={ | |
| 'row-tracking.enabled': 'true', | |
| 'data-evolution.enabled': 'true', | |
| }) | |
| write_builder = table.new_stream_write_builder() | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| callback = RecordingCallback() | |
| table_commit.add_commit_callback(callback) | |
| for i in range(3): | |
| data = pa.Table.from_pydict({ | |
| 'id': [i * 2, i * 2 + 1], | |
| 'name': [f'a{i}', f'b{i}'], | |
| 'dt': ['p1', 'p1'], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(data) | |
| table_commit.commit(table_write.prepare_commit(i), commit_identifier=i) | |
| self.assertEqual(3, len(callback.contexts)) | |
| # Row IDs must be assigned and monotonically increasing across commits | |
| prev_next_row_id = 0 | |
| for ctx in callback.contexts: | |
| for entry in ctx.commit_entries: | |
| self.assertIsNotNone(entry.file.first_row_id) | |
| self.assertGreaterEqual(entry.file.first_row_id, prev_next_row_id) | |
| self.assertGreater(ctx.snapshot.next_row_id, prev_next_row_id) | |
| prev_next_row_id = ctx.snapshot.next_row_id | |
| table_write.close() | |
| table_commit.close() | |
| if __name__ == '__main__': | |
| unittest.main() |