This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 8588fd3306 [python] Verify native REST Blob view dependency reads
(#10471)
8588fd3306 is described below
commit 8588fd3306310078915bd447ddd0c666390b2fb2
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 17:54:48 2026 +0800
[python] Verify native REST Blob view dependency reads (#10471)
---
.../pypaimon/tests/native_plan_rest_test.py | 338 +++++++++++++++++++++
1 file changed, 338 insertions(+)
diff --git a/paimon-python/pypaimon/tests/native_plan_rest_test.py
b/paimon-python/pypaimon/tests/native_plan_rest_test.py
index ef3294c2f3..b0c62de7c6 100644
--- a/paimon-python/pypaimon/tests/native_plan_rest_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_rest_test.py
@@ -361,3 +361,341 @@ def
test_repeated_plans_reuse_remote_file_sizes(rest_source, tmp_path):
server.shutdown()
server.server_close()
thread.join()
+
+
[email protected]
+def rest_blob_views(rest_catalog):
+ catalog, server = rest_catalog
+ schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary())])
+ common = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}
+ for name in ['upstream', 'middle', 'root']:
+ extra = {} if name == 'upstream' else {'blob-view-field': 'payload'}
+ catalog.create_table('default.' + name, Schema.from_pyarrow_schema(
+ schema, options=dict(common, **extra)), False)
+
+ def write(table, rows):
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ builder.new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+
+ upstream = catalog.get_table('default.upstream')
+ write(upstream, [{'id': 1, 'payload': b'actual'}, {'id': 2, 'payload':
None}])
+ middle = catalog.get_table('default.middle')
+ write(middle, [dict(id=i, payload=BlobViewStruct(
+ 'default.upstream', upstream.field_dict['payload'].id, i).serialize())
for i in [0, 1]])
+ root = catalog.get_table('default.root')
+ write(root, [dict(id=i, payload=BlobViewStruct(
+ middle.identifier, middle.field_dict['payload'].id, i).serialize())
for i in [0, 1]])
+ return catalog, server, upstream, middle, root
+
+
+def _require_blob_view_read_via(server, expected):
+ from pypaimon.api.rest_api import RESTApi
+ from pypaimon.api.rest_util import RESTUtil
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.json_util import JSON
+
+ route = server._route_request
+ requests = []
+
+ def require_read_via(method, resource_path, parameters, data, headers):
+ if any('/tables/' + name in resource_path for name in ['upstream',
'middle']
+ if name != expected.get_table_name()):
+ encoded = next((value for key, value in headers.items()
+ if key.lower() ==
RESTApi.READ_VIA_HEADER.lower()), None)
+ actual = JSON.from_json(RESTUtil.decode_string(encoded),
Identifier) if encoded else None
+ requests.append((resource_path, actual))
+ if actual != expected:
+ return server._mock_response(ErrorResponse(
+ 'TABLE', 'upstream', 'dependency needs root Read-Via',
403), 403)
+ return route(method, resource_path, parameters, data, headers)
+
+ return patch.object(server, '_route_request',
side_effect=require_read_via), requests
+
+
[email protected]_plan
[email protected]_read
[email protected]('native', [False, True])
[email protected]('nested', [False, True])
+def test_rest_blob_view_dependency_read_via(rest_blob_views, native, nested):
+ from contextlib import ExitStack
+ from pypaimon.table.row.blob import BlobDescriptor
+
+ _, server, _, middle, root = rest_blob_views
+ root = root if nested else middle
+ root = root.copy({'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower()})
+ guard, requests = _require_blob_view_read_via(server, root.identifier)
+ with guard, ExitStack() as stack:
+ if native:
+
stack.enter_context(patch('pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('native Blob
view read fell back')))
+ builder = root.new_read_builder()
+ rows =
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+ assert rows == [dict(id=0, payload=b'actual'), dict(id=1,
payload=None)]
+ descriptors = root.copy({'blob-as-descriptor':
'true'}).new_read_builder()
+ values =
descriptors.new_read().to_arrow(descriptors.new_scan().plan().splits()).sort_by('id')
+ assert
BlobDescriptor.is_blob_descriptor(values.column('payload')[0].as_py())
+ assert values.column('payload')[1].as_py() is None
+ assert requests and all(identifier == root.identifier for _, identifier in
requests)
+
+
[email protected]_plan
[email protected]_read
[email protected]('native', [False, True])
+def test_rest_blob_view_merge_source_keeps_dependency_context(rest_blob_views,
native):
+ from contextlib import ExitStack
+ from pypaimon.table.data_evolution_merge_into import WhenNotMatched
+
+ catalog, server, _, _, root = rest_blob_views
+ schema = pa.schema([('id', pa.int32()), ('payload', pa.large_binary())])
+ catalog.create_table('default.target', Schema.from_pyarrow_schema(schema,
options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'write.native.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower(),
+ 'scan.native-plan.enabled': str(native).lower(),
+ }), False)
+ target = catalog.get_table('default.target')
+ builder = target.new_batch_write_builder()
+ guard, requests = _require_blob_view_read_via(server, root.identifier)
+ with guard, ExitStack() as stack:
+ if native:
+
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+ side_effect=AssertionError('Python MERGE
source materialization')))
+ messages = builder.new_update().merge_into(root, on=['id'],
when_not_matched=[WhenNotMatched('*')])
+ builder.new_commit().commit(messages)
+ reader = target.new_read_builder()
+ assert
reader.new_read().to_arrow(reader.new_scan().plan().splits()).sort_by('id').to_pylist()
== [
+ dict(id=0, payload=b'actual'), dict(id=1, payload=None)]
+ assert requests and all(identifier == root.identifier for _, identifier in
requests)
+
+
[email protected]_plan
[email protected]_read
[email protected]('native', [False, True])
+def test_rest_blob_view_payload_uses_upstream_token(rest_blob_views, native):
+ import json
+ import time
+ from contextlib import ExitStack
+ from collections import Counter
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from pathlib import Path
+ from threading import Thread
+ from urllib.parse import urlparse
+
+ from pypaimon.api.api_response import GetTableTokenResponse
+ from pypaimon.catalog.rest.rest_token import RESTToken
+ from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
+ from pypaimon.common.identifier import Identifier
+
+ catalog, rest_server, upstream, middle, _ = rest_blob_views
+ roots = {table.identifier.get_table_name():
Path(urlparse(table.table_path).path)
+ for table in [upstream, middle]}
+ requests = []
+
+ class ObjectStore(BaseHTTPRequestHandler):
+ def do_HEAD(self):
+ self.serve()
+
+ def do_GET(self):
+ self.serve()
+
+ def serve(self):
+ path = urlparse(self.path).path
+ parts = path.split('/', 3)
+ if len(parts) != 4 or parts[1] != 'bucket' or parts[2] not in
roots:
+ self.send_error(404)
+ return
+ name, relative = parts[2:]
+ auth = self.headers.get('Authorization', '')
+ requests.append((name, relative, auth))
+ if 'Credential=' + name + '-key/' not in auth:
+ self.send_error(403, 'wrong table token')
+ return
+ file = roots[name] / relative
+ if not file.is_file():
+ self.send_error(404)
+ return
+ data = file.read_bytes()
+ size = len(data)
+ byte_range = self.headers.get('Range')
+ self.send_response(206 if byte_range else 200)
+ if byte_range:
+ start, end = byte_range[6:].split('-')
+ start, end = int(start), int(end) if end else size - 1
+ data = data[start:end + 1]
+ self.send_header('Content-Range', 'bytes %s-%s/%s' % (start,
end, size))
+ self.send_header('Content-Length', str(len(data)))
+ self.send_header('ETag', '"immutable-fixture"')
+ self.end_headers()
+ if self.command == 'GET':
+ self.wfile.write(data)
+
+ def log_message(self, *args):
+ pass
+
+ store = ThreadingHTTPServer(('127.0.0.1', 0), ObjectStore)
+ thread = Thread(target=store.serve_forever, daemon=True)
+ thread.start()
+ table_handle = rest_server._table_handle
+ token_handle = rest_server._table_token_handle
+ token_requests = Counter()
+
+ def remote_table(method, data, identifier, response_identifier=None):
+ body, status = table_handle(method, data, identifier,
response_identifier)
+ if method == 'GET' and identifier.get_table_name() in roots:
+ response = json.loads(body)
+ response['path'] = 's3://bucket/' + identifier.get_table_name()
+ body = json.dumps(response)
+ return body, status
+
+ def token(method, identifier):
+ name = identifier.get_table_name()
+ token_requests[name] += 1
+ # Force an upstream refresh without sleeps; it must keep Read-Via.
+ if name == 'upstream' and token_requests[name] == 1:
+ return rest_server._mock_response(GetTableTokenResponse(
+ token={'s3.access-key': name + '-key', 's3.secret-key':
'test-secret'},
+ expires_at_millis=0), 200)
+ return token_handle(method, identifier)
+
+ try:
+ options = dict(catalog.context.options.to_map(), **{
+ 's3.endpoint': 'http://127.0.0.1:%s' % store.server_port,
+ 's3.region': 'us-east-1', 's3.path.style.access': 'true',
+ 'data-token.enabled': 'true'})
+ for name in roots:
+ rest_server.set_table_token(Identifier('default', name),
RESTToken({
+ 's3.access-key': name + '-key', 's3.secret-key':
'test-secret'},
+ int(time.time() * 1000) + 7_200_000))
+ with patch.object(rest_server, '_table_handle',
side_effect=remote_table), \
+ patch.object(rest_server, '_table_token_handle',
side_effect=token), \
+ patch.object(RESTTokenFileIO, '_TOKEN_CACHE', {}):
+ view =
CatalogFactory.create(options).get_table('default.middle').copy({
+ 'scan.native-plan.enabled': str(native).lower(),
+ 'read.native.enabled': str(native).lower()})
+ guard, dependency_requests =
_require_blob_view_read_via(rest_server, view.identifier)
+ with guard, ExitStack() as stack:
+ if native:
+
stack.enter_context(patch('pypaimon.read.table_read.TableRead._create_split_read',
+
side_effect=AssertionError('token view read fell back')))
+ builder = view.new_read_builder()
+ result =
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id')
+ assert result.to_pylist() == [dict(id=0, payload=b'actual'),
dict(id=1, payload=None)]
+ assert dependency_requests
+ assert token_requests['upstream'] >= 2
+ assert any(name == 'upstream' and path.endswith('.blob') for name,
path, _ in requests)
+ assert all('Credential=' + name + '-key/' in auth for name, _, auth in
requests)
+ finally:
+ store.shutdown()
+ thread.join()
+ store.server_close()
+
+
[email protected]_plan
[email protected]_read
+def test_native_rest_blob_views_group_upstream_fields_by_id(rest_catalog):
+ from pypaimon.schema.schema_change import SchemaChange
+
+ catalog, server = rest_catalog
+ source_schema = pa.schema([('id', pa.int32()), ('other', pa.int32()),
('picture', pa.large_binary()),
+ ('thumbnail', pa.large_binary())])
+ options = {'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true'}
+ catalog.create_table('default.upstream',
Schema.from_pyarrow_schema(source_schema, options=options), False)
+ source = catalog.get_table('default.upstream')
+ writer = source.new_batch_write_builder().new_write()
+ try:
+ writer.write_arrow(pa.Table.from_pylist([
+ dict(id=1, other=10, picture=b'picture-0', thumbnail=b'thumb-0'),
+ dict(id=2, other=20, picture=b'picture-1', thumbnail=None),
+ ], schema=source_schema))
+
source.new_batch_write_builder().new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ schema = pa.schema([('id', pa.int32()), ('left', pa.large_binary()),
('right', pa.large_binary())])
+ catalog.create_table('default.grouped', Schema.from_pyarrow_schema(
+ schema, options=dict(options, **{'blob-view-field': 'left,right'})),
False)
+ view = catalog.get_table('default.grouped').copy({
+ 'scan.native-plan.enabled': 'true', 'read.native.enabled': 'true'})
+ picture_id = source.field_dict['picture'].id
+ thumbnail_id = source.field_dict['thumbnail'].id
+ refs = [dict(id=i, left=BlobViewStruct('default.upstream', picture_id, 1 -
i).serialize(),
+ right=BlobViewStruct('default.upstream', thumbnail_id,
i).serialize()) for i in [0, 1]]
+ writer = view.new_batch_write_builder().new_write()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(refs, schema=schema))
+
view.new_batch_write_builder().new_commit().commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ # Drop an ordinary column so BLOB field IDs differ from their positions.
+ catalog.alter_table('default.upstream', [SchemaChange.drop_column('id')])
+ guard, requests = _require_blob_view_read_via(server, view.identifier)
+ with guard, patch('pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('multi-field view read fell
back')):
+ builder = view.new_read_builder()
+ rows =
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+ assert rows == [dict(id=0, left=b'picture-1', right=b'thumb-0'),
+ dict(id=1, left=b'picture-0', right=None)]
+ metadata = [path for path, _ in requests if
path.endswith('/tables/upstream')]
+ snapshots = [path for path, _ in requests if
path.endswith('/tables/upstream/snapshot')]
+ assert len(metadata) == len(snapshots) == 1
+
+
[email protected]_plan
[email protected]_read
[email protected]('native', [False, True])
[email protected]('branch', [None, 'dev'])
[email protected]('explicit', [False, True])
+def
test_rest_blob_view_keeps_branch_and_explicit_outer_context(rest_blob_views,
native, branch, explicit):
+ from contextlib import ExitStack
+ from pypaimon.api.rest_api import RESTApi
+ from pypaimon.api.rest_util import RESTUtil
+ from pypaimon.common.identifier import Identifier
+ from pypaimon.common.json_util import JSON
+
+ catalog, server, _, _, root = rest_blob_views
+ if branch:
+ catalog.create_tag(root.identifier, 'base')
+ catalog.create_branch(root.identifier, branch, tag_name='base')
+ identifier = Identifier('default', 'root', branch=branch)
+ outer = Identifier('outer 空间', 'parent.with.dots')
+ header = RESTUtil.encode_string(JSON.to_json(outer, separators=(',', ':')))
+ options = dict(catalog.context.options.to_map())
+ if explicit:
+ options[RESTApi.HEADER_PREFIX + RESTApi.READ_VIA_HEADER] = header
+ view = CatalogFactory.create(options).get_table(identifier).copy({
+ 'scan.native-plan.enabled': str(native).lower(),
'read.native.enabled': str(native).lower()})
+ expected = outer if explicit else view.identifier
+ guard, requests = _require_blob_view_read_via(server, expected)
+ with guard, ExitStack() as stack:
+ if native:
+
stack.enter_context(patch('pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('branch view
read fell back')))
+ builder = view.new_read_builder()
+ result =
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id')
+ assert result.to_pylist() == [dict(id=0, payload=b'actual'), dict(id=1,
payload=None)]
+ assert requests and all(actual == expected for _, actual in requests)
+
+
[email protected]_plan
[email protected]_read
+def
test_native_rest_blob_view_does_not_bypass_denied_dependency(rest_blob_views):
+ _, server, _, _, root = rest_blob_views
+ root = root.copy({'scan.native-plan.enabled': 'true',
'read.native.enabled': 'true'})
+ route = server._route_request
+
+ def deny_upstream(method, path, parameters, data, headers):
+ if '/tables/upstream' in path:
+ return server._mock_response(ErrorResponse('TABLE', 'upstream',
'upstream denied', 403), 403)
+ return route(method, path, parameters, data, headers)
+
+ with patch.object(server, '_route_request', side_effect=deny_upstream), \
+ patch('pypaimon.read.table_read.TableRead._create_split_read',
+ side_effect=AssertionError('denied dependency fell back')):
+ builder = root.new_read_builder()
+ with pytest.raises(Exception, match='upstream denied'):
+ builder.new_read().to_arrow(builder.new_scan().plan().splits())