| # 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 json | |
| from types import SimpleNamespace | |
| from urllib.parse import unquote_plus, urlsplit | |
| import pyarrow as pa | |
| import pytest | |
| import requests | |
| from pypaimon import Schema | |
| from pypaimon.api.api_request import RollbackTableRequest | |
| from pypaimon.api.api_response import GetTableResponse | |
| from pypaimon.catalog.catalog_context import CatalogContext | |
| from pypaimon.catalog.catalog_exception import TableNotExistException, TableNoPermissionException | |
| from pypaimon.catalog.rest.rest_catalog import RESTCatalog | |
| from pypaimon.catalog.table_rollback import TableRollback | |
| from pypaimon.common.identifier import Identifier | |
| from pypaimon.common.json_util import JSON | |
| from pypaimon.common.options import Options | |
| from pypaimon.execution.native.table import (_resolved_rest_table_response) | |
| from pypaimon.snapshot.snapshot import Snapshot | |
| from pypaimon.table.instant import Instant | |
| from pypaimon.execution.native.write.native_commit import _rest_catalog_supported | |
| @pytest.fixture | |
| def transport(monkeypatch, tmp_path): | |
| state = SimpleNamespace(requests=[], uuid='loaded-table-uuid', status=200) | |
| schema = Schema.from_pyarrow_schema(pa.schema([('id', pa.int64())])) | |
| def send(session, request, **kwargs): | |
| state.requests.append(request) | |
| response = requests.Response() | |
| response.request = request | |
| response.url = request.url | |
| if request.method == 'GET': | |
| metadata = GetTableResponse( | |
| id=state.uuid, | |
| name=unquote_plus(urlsplit(request.url).path.rsplit('/', 1)[-1]), | |
| path=tmp_path.as_uri(), is_external=False, schema_id=0, schema=schema) | |
| response.status_code = 200 | |
| response._content = JSON.to_json(metadata).encode('utf-8') | |
| else: | |
| response.status_code = state.status | |
| response._content = (b'' if state.status == 200 else json.dumps({ | |
| 'code': state.status, 'message': 'table UUID mismatch', | |
| }).encode('utf-8')) | |
| return response | |
| monkeypatch.setattr(requests.Session, 'send', send) | |
| return state | |
| @pytest.fixture | |
| def catalog(transport): | |
| return RESTCatalog(CatalogContext.create_from_options(Options({ | |
| 'metastore': 'rest', 'uri': 'http://127.0.0.1:1', 'warehouse': 'warehouse', | |
| 'prefix': 'rollback-test', 'token.provider': 'bear', 'token': 'test-token', | |
| 'data-token.enabled': 'false', | |
| # This suite spies on requests.Session.send. Native wire behavior is | |
| # covered by the real HTTP Native version-management tests. | |
| 'read.native.enabled': 'false', 'write.native.enabled': 'false', | |
| })), config_required=False) | |
| @pytest.fixture | |
| def table(catalog, transport): | |
| loaded = catalog.get_table(Identifier('db', 'table')) | |
| transport.requests.clear() | |
| return loaded | |
| @pytest.fixture | |
| def compact_head(): | |
| return Snapshot( | |
| version=3, id=3, schema_id=0, base_manifest_list='base-list', | |
| delta_manifest_list='delta-list', total_record_count=0, delta_record_count=0, | |
| commit_user='snapshot-author', commit_identifier=1, commit_kind='COMPACT', | |
| time_millis=0, uuid='observed-head-uuid') | |
| def request_body(request): | |
| return json.loads(request.body) | |
| @pytest.mark.parametrize('table_uuid', [None, 'loaded-table-uuid']) | |
| def test_request_serialization(table_uuid): | |
| request = RollbackTableRequest(Instant.snapshot(1), 2, table_id=table_uuid) | |
| expected = {'instant': {'type': 'snapshot', 'snapshotId': 1}, 'fromSnapshot': 2} | |
| if table_uuid is not None: | |
| expected['tableId'] = table_uuid | |
| assert json.loads(JSON.to_json(request)) == expected | |
| def test_unguarded_request_omits_absent_guard(): | |
| assert json.loads(JSON.to_json(RollbackTableRequest(Instant.snapshot(1), None))) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 1}, | |
| } | |
| @pytest.mark.parametrize('target,instant', [ | |
| (1, {'type': 'snapshot', 'snapshotId': 1}), | |
| ('v1', {'type': 'tag', 'tagName': 'v1'}), | |
| ]) | |
| def test_table_rollback_transmits_loaded_uuid(table, transport, target, instant, monkeypatch): | |
| def unexpected_local_access(): | |
| pytest.fail('REST rollback must not access local snapshot or tag metadata') | |
| monkeypatch.setattr(table, 'rollback_helper', unexpected_local_access) | |
| monkeypatch.setattr(table, 'tag_manager', unexpected_local_access) | |
| monkeypatch.setattr(table, 'snapshot_manager', unexpected_local_access) | |
| table.rollback_to(target) | |
| assert len(transport.requests) == 1 | |
| assert transport.requests[0].method == 'POST' | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': instant, 'tableId': 'loaded-table-uuid', | |
| } | |
| @pytest.mark.parametrize('snapshot_uuid', ['observed-head-uuid', 'missing', None]) | |
| @pytest.mark.parametrize('branch', [None, 'dev / blue']) | |
| def test_conditional_rollback_transmits_uuid_and_expected_head( | |
| catalog, transport, compact_head, snapshot_uuid, branch): | |
| payload = json.loads(JSON.to_json(compact_head)) | |
| if snapshot_uuid == 'missing': | |
| payload.pop('uuid') | |
| else: | |
| payload['uuid'] = snapshot_uuid | |
| head = JSON.from_json(json.dumps(payload), Snapshot) | |
| assert head.uuid == (None if snapshot_uuid == 'missing' else snapshot_uuid) | |
| identifier = Identifier('db', 'table', branch=branch) | |
| table = catalog.get_table(identifier) | |
| transport.requests.clear() | |
| table.catalog_environment.catalog_table_rollback().rollback_to( | |
| Instant.snapshot(head.id - 1), head.id) | |
| assert len(transport.requests) == 1 | |
| assert transport.requests[0].method == 'POST' | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 2}, | |
| 'fromSnapshot': 3, 'tableId': 'loaded-table-uuid', | |
| } | |
| object_name = unquote_plus(urlsplit(transport.requests[0].url).path.split('/')[-2]) | |
| assert object_name == identifier.get_object_name() | |
| @pytest.mark.parametrize('entrypoint', ['catalog', 'api']) | |
| def test_direct_rollback_accepts_optional_uuid(catalog, transport, entrypoint): | |
| delegate = catalog if entrypoint == 'catalog' else catalog.rest_api | |
| delegate.rollback_to(Identifier('db', 'table'), Instant.snapshot(1), 2, table_uuid='expected-uuid') | |
| assert len(transport.requests) == 1 | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 1}, | |
| 'fromSnapshot': 2, 'tableId': 'expected-uuid', | |
| } | |
| def test_existing_calls_and_encoded_branch_url(catalog, transport): | |
| identifier = Identifier('db space', 'table name', branch='dev / blue') | |
| catalog.rollback_to(identifier, Instant.snapshot(1)) | |
| catalog.rollback_to(identifier, Instant.snapshot(1), 2) | |
| catalog.rollback_to('db.table', Instant.tag('v1')) | |
| assert [request_body(request).get('fromSnapshot') for request in transport.requests] == [None, 2, None] | |
| assert [set(request_body(request)) for request in transport.requests] == [ | |
| {'instant'}, {'instant', 'fromSnapshot'}, {'instant'}] | |
| path = urlsplit(transport.requests[0].url).path.split('/') | |
| assert len(path) == 8 | |
| assert unquote_plus(path[4]) == identifier.get_database_name() | |
| assert unquote_plus(path[6]) == identifier.get_object_name() | |
| assert path[7] == 'rollback' | |
| def test_table_rollback_without_uuid(catalog, transport): | |
| transport.uuid = None | |
| table = catalog.get_table(Identifier('db', 'table')) | |
| transport.requests.clear() | |
| table.rollback_to(1) | |
| assert len(transport.requests) == 1 | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 1}, | |
| } | |
| @pytest.mark.parametrize('status,exception', [(403, TableNoPermissionException), (404, TableNotExistException)]) | |
| def test_existing_error_mapping(catalog, transport, status, exception): | |
| transport.status = status | |
| with pytest.raises(exception): | |
| catalog.rollback_to(Identifier('db', 'table'), Instant.snapshot(1), table_uuid='expected-uuid') | |
| assert len(transport.requests) == 1 | |
| assert request_body(transport.requests[0])['tableId'] == 'expected-uuid' | |
| @pytest.mark.parametrize('target', [1, 'v1']) | |
| def test_replaced_table_does_not_refresh_retry_or_clean_locally(table, transport, monkeypatch, target): | |
| transport.uuid = 'replacement-table-uuid' | |
| transport.status = 400 | |
| def unexpected_cleanup(): | |
| pytest.fail('Catalog rejection must not trigger local cleanup') | |
| monkeypatch.setattr(table, 'rollback_helper', unexpected_cleanup) | |
| with pytest.raises(RuntimeError, match='table UUID mismatch'): | |
| table.rollback_to(target) | |
| assert len(transport.requests) == 1 | |
| assert transport.requests[0].method == 'POST' | |
| assert request_body(transport.requests[0])['tableId'] == 'loaded-table-uuid' | |
| @pytest.mark.parametrize('status', [400, 403, 404, 409]) | |
| def test_conditional_rollback_rejection_preserves_identity(table, transport, compact_head, monkeypatch, status): | |
| transport.status = status | |
| def unexpected_cleanup(): | |
| pytest.fail('Catalog rejection must not trigger local cleanup') | |
| monkeypatch.setattr(table, 'rollback_helper', unexpected_cleanup) | |
| with pytest.raises(RuntimeError, match="Failed to rollback table"): | |
| table.catalog_environment.catalog_table_rollback().rollback_to( | |
| Instant.snapshot(compact_head.id - 1), compact_head.id) | |
| assert len(transport.requests) == 1 | |
| assert transport.requests[0].method == 'POST' | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 2}, | |
| 'fromSnapshot': 3, 'tableId': 'loaded-table-uuid', | |
| } | |
| @pytest.mark.parametrize('table_uuid', [None, 'known-uuid']) | |
| def test_legacy_catalog_override_never_discards_known_uuid(table_uuid): | |
| calls = [] | |
| class LegacyCatalog: | |
| def rollback_to(self, identifier, instant, from_snapshot=None): | |
| calls.append((identifier, instant, from_snapshot)) | |
| identifier = Identifier('db', 'table') | |
| instant = Instant.snapshot(1) | |
| if table_uuid is None: | |
| TableRollback(LegacyCatalog(), identifier).rollback_to(instant, 2) | |
| assert calls == [(identifier, instant, 2)] | |
| else: | |
| with pytest.raises(RuntimeError) as failure: | |
| TableRollback(LegacyCatalog(), identifier, table_uuid).rollback_to(instant, 2) | |
| assert isinstance(failure.value.__cause__, TypeError) | |
| assert calls == [] | |
| @pytest.mark.parametrize('source,target', [(None, 'dev'), ('dev', None), ('dev', 'release')]) | |
| @pytest.mark.parametrize('conditional', [False, True]) | |
| def test_copied_branch_rollback_omits_source_identity(catalog, transport, compact_head, source, target, conditional): | |
| table = catalog.get_table(Identifier('db', 'table', branch=source)) | |
| copied = table.copy({'branch': target}) | |
| transport.requests.clear() | |
| if conditional: | |
| copied.catalog_environment.catalog_table_rollback().rollback_to( | |
| Instant.snapshot(compact_head.id - 1), compact_head.id) | |
| else: | |
| copied.rollback_to(1) | |
| assert table.catalog_environment.uuid == 'loaded-table-uuid' | |
| assert copied.catalog_environment.uuid is None | |
| assert len(transport.requests) == 1 | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 2 if conditional else 1}, | |
| **({'fromSnapshot': 3} if conditional else {}), | |
| } | |
| object_name = unquote_plus(urlsplit(transport.requests[0].url).path.split('/')[-2]) | |
| assert object_name == Identifier('db', 'table', branch=target).get_object_name() | |
| @pytest.mark.parametrize('conditional', [False, True]) | |
| def test_same_branch_copy_and_independent_load_preserve_identity(table, catalog, transport, compact_head, conditional): | |
| assert table.copy({'scan.parallelism': '2'}).catalog_environment.uuid == 'loaded-table-uuid' | |
| assert table.copy({'branch': 'main'}).catalog_environment.uuid == 'loaded-table-uuid' | |
| assert _rest_catalog_supported(table) | |
| assert not _rest_catalog_supported(table.copy({'branch': 'dev'})) | |
| transport.uuid = 'loaded-dev-uuid' | |
| branch = catalog.get_table(Identifier('db', 'table', branch='dev')) | |
| assert branch.current_branch() == 'dev' | |
| assert branch.catalog_environment.uuid == 'loaded-dev-uuid' | |
| assert branch.copy({'branch': 'dev'}).catalog_environment.uuid == 'loaded-dev-uuid' | |
| assert branch.catalog_environment.snapshot_commit(None).uuid == 'loaded-dev-uuid' | |
| assert _rest_catalog_supported(branch) | |
| transport.requests.clear() | |
| if conditional: | |
| copied = branch.copy({'branch': 'dev'}) | |
| copied.catalog_environment.catalog_table_rollback().rollback_to( | |
| Instant.snapshot(compact_head.id - 1), compact_head.id) | |
| else: | |
| branch.rollback_to(1) | |
| assert len(transport.requests) == 1 | |
| assert request_body(transport.requests[0]) == { | |
| 'instant': {'type': 'snapshot', 'snapshotId': 2 if conditional else 1}, | |
| **({'fromSnapshot': 3} if conditional else {}), 'tableId': 'loaded-dev-uuid', | |
| } | |
| object_name = unquote_plus(urlsplit(transport.requests[0].url).path.split('/')[-2]) | |
| assert object_name == Identifier('db', 'table', branch='dev').get_object_name() | |
| def test_copied_branch_rejects_source_read_metadata(table, monkeypatch): | |
| monkeypatch.setattr('pypaimon.execution.native.runtime.native_runtime_available', lambda *args: True) | |
| assert _resolved_rest_table_response(table) == table.catalog_environment.rest_table_response | |
| assert _resolved_rest_table_response(table.copy({'branch': 'main'})) is not None | |
| assert _resolved_rest_table_response(table.copy({'branch': 'dev'})) is None |