blob: 19bff6433f54ffee48267d588554c90a6ab1e81b [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.
"""REST version management uses the Python client and Java wire semantics."""
import json
from unittest.mock import patch
from urllib.parse import parse_qs, unquote_plus, urlsplit
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.api.rest_exception import BadRequestException, NotImplementedException, ServiceFailureException
from pypaimon.catalog.catalog_exception import (
TableNotExistException, TableNoPermissionException, TagAlreadyExistException, TagNotExistException,
)
from pypaimon.common.identifier import Identifier
from pypaimon.common.json_util import JSON
from pypaimon.table.instant import Instant
from pypaimon.tests.rest import rest_branch_transport_test as branch_fixtures
transport_server = branch_fixtures.transport_server
_OPERATIONS = ('create_tag', 'get_tag', 'list_tags_paged', 'delete_tag', 'load_snapshot', 'rollback_to')
_SNAPSHOT = dict(version=3, id=7, schemaId=0, baseManifestList='base', deltaManifestList='delta',
commitUser='writer', commitIdentifier=1, commitKind='APPEND', timeMillis=123,
uuid='snapshot-uuid', totalRecordCount=8, deltaRecordCount=1, properties={'source': 'test'},
baseManifestListSize=321, deltaManifestListSize=0)
@pytest.fixture
def version_server(transport_server, tmp_path):
server = transport_server
server.require_v4 = True
server.uuid, server.head, server.status = 'incarnation-A', 9, None
server.tags, server.next_token = ['z', 'a', 'z'], 'next +/%一'
server.error_resource, server.error_name = None, None
server.snapshot_response = dict(snapshot=dict(
snapshot=_SNAPSHOT, recordCount=0, fileCount=2, fileSizeInBytes=123, lastFileCreationTime=456))
schema = Schema.from_pyarrow_schema(pa.schema([('id', pa.int64())]))
def respond(method, path, data):
if server.status is not None:
return server.status, json.dumps(dict(
resourceType=server.error_resource, resourceName=server.error_name, message='rejected'))
parsed = urlsplit(path)
if parsed.path.endswith('/rollback'):
body = json.loads(data)
if 'tableId' in body and body['tableId'] != server.uuid:
return 400, json.dumps({'message': 'table UUID mismatch'})
if 'fromSnapshot' in body and body['fromSnapshot'] != server.head:
return 400, json.dumps({'message': 'snapshot head mismatch'})
instant = body['instant']
server.head = instant['snapshotId'] if instant['type'] == 'snapshot' else 7
return 200, 'rolled back'
if parsed.path.endswith('/snapshot'):
return 200, json.dumps(server.snapshot_response)
if parsed.path.endswith('/tags'):
return (200, json.dumps(dict(tags=server.tags, nextPageToken=server.next_token))) \
if method == 'GET' else (200, 'created')
if '/tags/' in parsed.path:
return (200, json.dumps(dict(
tagName=unquote_plus(parsed.path.rsplit('/', 1)[1]), snapshot=_SNAPSHOT,
tagCreateTime=123, tagTimeRetained='2 d'))) if method == 'GET' else (200, 'deleted')
return 200, json.dumps(dict(
id=server.uuid, name=unquote_plus(parsed.path.rsplit('/', 1)[1]),
schema=json.loads(JSON.to_json(schema)), schemaId=0, path=tmp_path.as_uri(), isExternal=False))
server.respond = respond
return server
def make_catalog(server, native=True):
return branch_fixtures.transport_catalog(server, **{
'token.provider': 'dlf', 'dlf.region': 'cn-hangzhou', 'dlf.signing-algorithm': 'openapi-v4',
'dlf.access-key-id': 'test-key', 'dlf.access-key-secret': 'test-secret', 'dlf.security-token': 'test-sts',
'read.native.enabled': str(native).lower(), 'write.native.enabled': str(native).lower(),
})
def invoke(catalog, operation, identifier=Identifier('db /一', 'table', branch='dev /一')):
if operation == 'create_tag':
return catalog.create_tag(identifier, 'release /一', 7, '2 d')
if operation in ('get_tag', 'delete_tag'):
return getattr(catalog, operation)(identifier, 'release /一')
if operation == 'list_tags_paged':
return catalog.list_tags_paged(identifier, 3, 'next +/%一', 'prefix +/%一')
if operation == 'rollback_to':
return catalog.rollback_to(identifier, Instant.snapshot(7), 9, table_uuid='incarnation-A')
return catalog.load_snapshot(identifier)
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_version_operations_use_python_with_v4_and_native_options(version_server, operation):
server = version_server
catalog = make_catalog(server)
server.requests.clear()
server.bodies.clear()
with branch_fixtures.python_rest_only(), \
patch.object(catalog.rest_api, operation, wraps=getattr(catalog.rest_api, operation)) as execute:
result = invoke(catalog, operation)
execute.assert_called_once()
assert len(server.requests) == 1
method, path, headers = server.requests[0]
assert headers['Authorization'].startswith('ACS4-HMAC-SHA256 ')
assert headers['x-acs-action'] == {
'create_tag': 'CreateTag', 'get_tag': 'GetTag', 'list_tags_paged': 'ListTags',
'delete_tag': 'DropTag', 'load_snapshot': 'GetTableSnapshot', 'rollback_to': 'RollbackToSnapshot',
}[operation]
assert '/tables/table%24branch_dev+%2F%E4%B8%80/' in path
if operation == 'create_tag':
assert server.bodies == [dict(tagName='release /一', snapshotId=7, timeRetained='2 d')]
elif operation == 'rollback_to':
assert server.bodies == [dict(
instant=dict(type='snapshot', snapshotId=7), fromSnapshot=9, tableId='incarnation-A')]
assert server.head == 7
elif operation == 'get_tag':
assert result.tag_name == 'release /一'
assert result.snapshot.id == 7
assert result.snapshot.uuid == 'snapshot-uuid'
assert (result.snapshot.base_manifest_list_size, result.snapshot.delta_manifest_list_size) == (321, 0)
assert result.tag_create_time == 123 and result.tag_time_retained == '2 d'
elif operation == 'list_tags_paged':
assert result.elements == ['z', 'a', 'z'] and result.next_page_token == 'next +/%一'
assert parse_qs(urlsplit(path).query) == {
'maxResults': ['3'], 'pageToken': ['next +/%一'], 'tagNamePrefix': ['prefix +/%一']}
elif operation == 'load_snapshot':
assert result.snapshot.id == 7 and result.snapshot.uuid == 'snapshot-uuid'
assert (result.snapshot.base_manifest_list_size, result.snapshot.delta_manifest_list_size) == (321, 0)
assert (result.record_count, result.file_count, result.file_size_in_bytes,
result.last_file_creation_time) == (0, 2, 123, 456)
else:
assert result is None and method == 'DELETE'
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('tags,expected_token', [(None, None), ([], 'next +/%一')])
def test_null_tag_page_ends_paging_but_empty_page_preserves_token(version_server, native, tags, expected_token):
server = version_server
catalog = make_catalog(server, native)
server.tags = tags
server.requests.clear()
page = catalog.list_tags_paged('db.table', 0, '', ' ')
assert page.elements == [] and page.next_page_token == expected_token
assert parse_qs(urlsplit(server.requests[0][1]).query, keep_blank_values=True) == {
'pageToken': [''], 'tagNamePrefix': [' ']}
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('instant', [Instant.snapshot(7), Instant.tag('release /一')])
def test_optional_rollback_guards_are_omitted_on_both_paths(version_server, native, instant):
server = version_server
catalog = make_catalog(server, native)
server.bodies.clear()
catalog.rollback_to('db.table', instant)
assert server.bodies == [{'instant': instant.to_dict()}]
@pytest.mark.parametrize('operation', _OPERATIONS)
@pytest.mark.parametrize('status', [403, 404])
def test_version_errors_keep_public_exception_types(version_server, operation, status):
server = version_server
catalog = make_catalog(server)
server.requests.clear()
server.status, server.error_resource, server.error_name = status, 'TABLE', 'db.table'
expected = TableNoPermissionException if status == 403 else TableNotExistException
with branch_fixtures.python_rest_only():
with pytest.raises(expected):
invoke(catalog, operation)
assert len(server.requests) == 1
@pytest.mark.parametrize('operation,resource,expected', [
('create_tag', 'TAG', TagAlreadyExistException),
('create_tag', 'SNAPSHOT', ValueError),
('get_tag', 'TAG', TagNotExistException),
('delete_tag', 'TAG', TagNotExistException),
('rollback_to', 'TAG', ValueError),
('rollback_to', 'SNAPSHOT', ValueError),
])
def test_missing_tag_and_snapshot_errors_remain_distinct(version_server, operation, resource, expected):
server = version_server
catalog = make_catalog(server)
server.status = 409 if expected is TagAlreadyExistException else 404
server.error_resource, server.error_name = resource, '7' if resource == 'SNAPSHOT' else 'release /一'
with pytest.raises(expected):
invoke(catalog, operation)
@pytest.mark.parametrize('operation', ['create_tag', 'delete_tag', 'rollback_to'])
def test_failure_after_publication_has_no_request_replay_or_local_cleanup(version_server, operation, tmp_path):
server = version_server
catalog = make_catalog(server)
sentinel = tmp_path / 'shared-data.parquet'
sentinel.write_bytes(b'existing data')
server.status = 500
server.requests.clear()
with branch_fixtures.python_rest_only():
with pytest.raises(ServiceFailureException):
invoke(catalog, operation)
assert len(server.requests) == 1
assert sentinel.read_bytes() == b'existing data'
@pytest.mark.parametrize('rejection', ['identity', 'head'])
def test_loaded_table_rollback_preserves_identity_and_never_reloads_on_rejection(version_server, rejection):
server = version_server
catalog = make_catalog(server)
table = catalog.get_table('db.table')
server.requests.clear()
server.bodies.clear()
if rejection == 'identity':
server.uuid = 'incarnation-B'
with patch.object(table, 'rollback_helper', side_effect=AssertionError('local cleanup')):
if rejection == 'identity':
with pytest.raises(RuntimeError, match='table UUID mismatch'):
table.rollback_to(7)
else:
with pytest.raises(BadRequestException, match='snapshot head mismatch'):
catalog.rollback_to('db.table', Instant.snapshot(7), 8, table_uuid='incarnation-A')
assert len(server.requests) == 1
assert server.bodies[0]['tableId'] == 'incarnation-A'
assert server.head == 9
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('response', [None, {}, {'snapshot': None}])
def test_empty_snapshot_responses_are_empty_views(version_server, native, response):
server = version_server
catalog = make_catalog(server, native)
server.snapshot_response = response
assert catalog.load_snapshot('db.table') is None
def test_empty_snapshot_error_is_not_a_missing_table(version_server):
server = version_server
catalog = make_catalog(server)
server.status, server.error_resource = 404, 'SNAPSHOT'
assert catalog.load_snapshot('db.table') is None
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_unimplemented_version_endpoints_keep_rest_exception_and_never_replay(version_server, operation):
server = version_server
catalog = make_catalog(server)
server.status = 501
server.requests.clear()
with branch_fixtures.python_rest_only():
with pytest.raises(NotImplementedException):
invoke(catalog, operation)
assert len(server.requests) == 1
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('snapshot_name', ['null', None])
def test_create_tag_on_empty_table_preserves_missing_snapshot(version_server, native, snapshot_name):
server = version_server
catalog = make_catalog(server, native)
server.status, server.error_resource, server.error_name = 404, 'SNAPSHOT', snapshot_name
server.requests.clear()
with pytest.raises(ValueError, match='Snapshot'):
catalog.create_tag('db.table', 'release')
assert len(server.requests) == 1
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('operation', ['create_tag', 'get_tag', 'delete_tag'])
@pytest.mark.parametrize('resource', ['tag', 'snapshot'])
def test_resource_types_use_java_case_sensitive_names(version_server, native, operation, resource):
server = version_server
catalog = make_catalog(server, native)
server.status, server.error_resource, server.error_name = 404, resource, '7'
with pytest.raises(TableNotExistException):
invoke(catalog, operation)
@pytest.mark.parametrize('operation', _OPERATIONS)
def test_invalid_version_resource_paths_send_no_requests(version_server, operation):
catalog = make_catalog(version_server)
version_server.requests.clear()
with branch_fixtures.python_rest_only():
for identifier in (Identifier('..', 'table'), Identifier('db', '.')):
with pytest.raises(ValueError):
invoke(catalog, operation, identifier)
if operation in ('get_tag', 'delete_tag'):
for name in ('.', '..'):
with pytest.raises(ValueError):
getattr(catalog, operation)('db.table', name)
assert version_server.requests == []