| # 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 |
| import os |
| import shutil |
| import tempfile |
| import unittest |
| from unittest.mock import patch |
| |
| import pyarrow as pa |
| import pyarrow.compute as pc |
| |
| from pypaimon import CatalogFactory, Schema |
| from pypaimon.catalog.table_query_auth import TableQueryAuthResult |
| from pypaimon.read.query_auth_split import QueryAuthSplit |
| from pypaimon.table.row.blob import BlobRef |
| |
| |
| _ROW_COUNT = 10 |
| _TABLE_OPTIONS = { |
| "row-tracking.enabled": "true", |
| "data-evolution.enabled": "true", |
| } |
| |
| |
| class _BlobCountingFileIO: |
| |
| def __init__(self, inner): |
| self._inner = inner |
| self.blobs_fetched = 0 |
| self.concurrent_read_calls = 0 |
| |
| def read_blobs_concurrent(self, blobs, parallelism): |
| self.concurrent_read_calls += 1 |
| self.blobs_fetched += sum(blob is not None for blob in blobs) |
| return self._inner.read_blobs_concurrent(blobs, parallelism) |
| |
| def __getattr__(self, name): |
| return getattr(self._inner, name) |
| |
| |
| class _RejectScoreOneAuthResult: |
| column_masking = None |
| filter = [json.dumps({ |
| "kind": "LEAF", |
| "transform": { |
| "name": "FIELD_REF", |
| "fieldRef": {"index": 2, "name": "score", "type": "INT"}, |
| }, |
| "function": "NOT_EQUAL", |
| "literals": [1], |
| })] |
| |
| @staticmethod |
| def get_extra_fields_for_filter(read_fields, table_fields): |
| return [] |
| |
| @staticmethod |
| def extract_row_filter(): |
| return lambda batch: pc.not_equal(batch.column("score"), 1) |
| |
| |
| class _PayloadAuthResult: |
| column_masking = None |
| |
| def __init__(self, expected_payload): |
| self._expected_payload = expected_payload |
| self.filter = [json.dumps({ |
| "kind": "LEAF", |
| "transform": { |
| "name": "FIELD_REF", |
| "fieldRef": { |
| "index": 1, |
| "name": "payload", |
| "type": "BYTES", |
| }, |
| }, |
| "function": "EQUAL", |
| "literals": [], |
| })] |
| |
| @staticmethod |
| def get_extra_fields_for_filter(read_fields, table_fields): |
| return [] |
| |
| def extract_row_filter(self): |
| return lambda batch: pc.equal( |
| batch.column("payload"), self._expected_payload) |
| |
| |
| class DeferredBlobResolveTest(unittest.TestCase): |
| |
| @classmethod |
| def setUpClass(cls): |
| cls.tempdir = tempfile.mkdtemp() |
| original_to_data = BlobRef.to_data |
| |
| def counted_to_data(blob): |
| file_io = getattr(blob._uri_reader, "_file_io", None) |
| if isinstance(file_io, _BlobCountingFileIO): |
| file_io.blobs_fetched += 1 |
| return original_to_data(blob) |
| |
| cls.blob_ref_to_data_patch = patch.object( |
| BlobRef, "to_data", counted_to_data) |
| cls.blob_ref_to_data_patch.start() |
| cls.catalog = CatalogFactory.create({ |
| "warehouse": os.path.join(cls.tempdir, "warehouse") |
| }) |
| cls.catalog.create_database("default", False) |
| cls.schema = pa.schema([ |
| ("sample_id", pa.string()), |
| ("payload", pa.large_binary()), |
| ("score", pa.int32()), |
| ]) |
| |
| @classmethod |
| def tearDownClass(cls): |
| cls.blob_ref_to_data_patch.stop() |
| shutil.rmtree(cls.tempdir, ignore_errors=True) |
| |
| def _create_table(self, name, extra_options=None, payloads=None, |
| partition_keys=None, sample_ids=None): |
| options = dict(_TABLE_OPTIONS) |
| options.update(extra_options or {}) |
| identifier = "default.%s" % name |
| self.catalog.create_table( |
| identifier, |
| Schema.from_pyarrow_schema( |
| self.schema, |
| partition_keys=partition_keys, |
| options=options, |
| ), |
| False, |
| ) |
| table = self.catalog.get_table(identifier) |
| write_builder = table.new_batch_write_builder() |
| writer = write_builder.new_write() |
| commit = write_builder.new_commit() |
| if sample_ids is None: |
| sample_ids = [ |
| "sample_%d" % index for index in range(_ROW_COUNT) |
| ] |
| writer.write_arrow(pa.table({ |
| "sample_id": sample_ids, |
| "payload": ( |
| payloads if payloads is not None else |
| [bytes([index]) * 1024 for index in range(_ROW_COUNT)] |
| ), |
| "score": list(range(_ROW_COUNT)), |
| }, schema=self.schema)) |
| commit.commit(writer.prepare_commit()) |
| writer.close() |
| commit.close() |
| return self.catalog.get_table(identifier) |
| |
| def _read(self, table, predicate, limit=None, blob_parallelism=None): |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| read_builder = table.new_read_builder() |
| if predicate is not None: |
| read_builder = read_builder.with_filter(predicate) |
| read_builder = read_builder.with_projection( |
| ["sample_id", "payload", "score"]) |
| if limit is not None: |
| read_builder = read_builder.with_limit(limit) |
| splits = read_builder.new_scan().plan().splits() |
| table_read = read_builder.new_read() |
| if blob_parallelism is None: |
| batch_reader = table_read.to_arrow_batch_reader(splits) |
| else: |
| batch_reader = table_read.to_arrow_batch_reader( |
| splits, blob_parallelism=blob_parallelism) |
| result = pa.Table.from_batches(batch_reader) |
| return result, counting_file_io |
| |
| def test_fetches_payloads_only_for_filtered_rows(self): |
| table = self._create_table("defer_filtered") |
| predicate = table.new_read_builder().new_predicate_builder().less_than( |
| "score", 5) |
| |
| result, counting_file_io = self._read(table, predicate) |
| |
| self.assertEqual(5, result.num_rows) |
| self.assertEqual(5, counting_file_io.blobs_fetched) |
| self.assertEqual(0, counting_file_io.concurrent_read_calls) |
| self.assertEqual( |
| [bytes([index]) * 1024 for index in range(5)], |
| result.column("payload").to_pylist(), |
| ) |
| |
| def test_applies_limit_before_fetching_payloads(self): |
| table = self._create_table("defer_limit") |
| predicate = table.new_read_builder().new_predicate_builder().less_than( |
| "score", 8) |
| |
| result, counting_file_io = self._read(table, predicate, limit=2) |
| |
| self.assertEqual(2, result.num_rows) |
| self.assertEqual(2, counting_file_io.blobs_fetched) |
| |
| def test_limit_without_predicate_defers_payloads(self): |
| table = self._create_table("defer_limit_only") |
| |
| result, counting_file_io = self._read(table, None, limit=2) |
| |
| self.assertEqual(2, result.num_rows) |
| self.assertEqual(2, counting_file_io.blobs_fetched) |
| |
| def test_limit_does_not_prefetch_payloads_across_splits(self): |
| table = self._create_table( |
| "defer_limit_splits", |
| extra_options={"source.split.target-size": "1b"}, |
| partition_keys=["sample_id"], |
| ) |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| splits = table.new_read_builder().new_scan().plan().splits() |
| read_builder = table.new_read_builder().with_limit(1) |
| |
| table_read = read_builder.new_read() |
| with patch.object( |
| table_read, |
| "_to_arrow_parallel", |
| side_effect=AssertionError("deferred LIMIT must run serially"), |
| ) as parallel_read: |
| result = table_read.to_arrow(splits, parallelism=4) |
| |
| self.assertGreater(len(splits), 1) |
| parallel_read.assert_not_called() |
| self.assertEqual(1, result.num_rows) |
| self.assertEqual(1, counting_file_io.blobs_fetched) |
| |
| def test_limit_covering_all_rows_preserves_parallelism(self): |
| table = self._create_table( |
| "defer_limit_all_rows", |
| extra_options={"source.split.target-size": "1b"}, |
| partition_keys=["sample_id"], |
| ) |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| read_builder = table.new_read_builder().with_limit(_ROW_COUNT) |
| splits = read_builder.new_scan().plan().splits() |
| table_read = read_builder.new_read() |
| |
| with patch.object( |
| table_read, |
| "_to_arrow_parallel", |
| wraps=table_read._to_arrow_parallel, |
| ) as parallel_read: |
| result = table_read.to_arrow(splits, parallelism=4) |
| |
| self.assertGreater(len(splits), 1) |
| parallel_read.assert_called_once() |
| self.assertEqual(_ROW_COUNT, result.num_rows) |
| self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched) |
| |
| def test_iterator_passes_remaining_limit_across_splits(self): |
| table = self._create_table( |
| "defer_iterator_limit_splits", |
| extra_options={"source.split.target-size": "1b"}, |
| partition_keys=["sample_id"], |
| sample_ids=["a"] + ["b"] * (_ROW_COUNT - 1), |
| ) |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload", "score"] |
| ).with_limit(2) |
| splits = read_builder.new_scan().plan().splits() |
| |
| rows = list(read_builder.new_read().to_iterator(splits)) |
| |
| self.assertEqual(2, len(splits)) |
| self.assertEqual(2, len(rows)) |
| self.assertEqual(2, counting_file_io.blobs_fetched) |
| |
| def test_iterator_applies_limit_after_auth_filter(self): |
| table = self._create_table( |
| "defer_iterator_auth_limit_splits", |
| extra_options={"source.split.target-size": "1b"}, |
| partition_keys=["sample_id"], |
| sample_ids=["a"] + ["b"] * (_ROW_COUNT - 1), |
| ) |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload", "score"] |
| ).with_limit(2) |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| auth_result = _RejectScoreOneAuthResult() |
| splits = [ |
| QueryAuthSplit(split, auth_result) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| scores = [ |
| row.get_field(2) |
| for row in read_builder.new_read().to_iterator(splits) |
| ] |
| |
| self.assertEqual([0, 2], scores) |
| self.assertEqual(2, counting_file_io.blobs_fetched) |
| |
| def test_auth_blob_filter_keeps_eager_resolution(self): |
| table = self._create_table("defer_auth_blob_filter") |
| expected_payload = bytes([3]) * 1024 |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload", "score"] |
| ).with_limit(1) |
| auth_result = _PayloadAuthResult(expected_payload) |
| splits = [ |
| QueryAuthSplit(split, auth_result) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| result = pa.Table.from_batches( |
| read_builder.new_read().to_arrow_batch_reader( |
| splits, blob_parallelism=4) |
| ) |
| |
| self.assertEqual([expected_payload], result.column("payload").to_pylist()) |
| self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched) |
| |
| def test_auth_only_defers_non_auth_payloads(self): |
| # An auth filter with no predicate/limit still defers scalar BLOBs, so payloads of |
| # the rows the auth filter drops are not read. |
| table = self._create_table("defer_auth_only") |
| counting_file_io = _BlobCountingFileIO(table.file_io) |
| table.file_io = counting_file_io |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload", "score"]) |
| splits = [ |
| QueryAuthSplit(split, _RejectScoreOneAuthResult()) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| result = pa.Table.from_batches( |
| read_builder.new_read().to_arrow_batch_reader( |
| splits, blob_parallelism=4)) |
| |
| self.assertEqual(_ROW_COUNT - 1, result.num_rows) |
| self.assertEqual(_ROW_COUNT - 1, counting_file_io.blobs_fetched) |
| |
| def test_auth_filter_preserves_file_io_for_blob_descriptors(self): |
| table = self._create_table( |
| "auth_blob_descriptor", |
| extra_options={"blob-as-descriptor": "true"}, |
| ) |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload", "score"]) |
| splits = [ |
| QueryAuthSplit(split, _RejectScoreOneAuthResult()) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| payloads = [ |
| row.get_blob(1).to_data() |
| for row in read_builder.new_read().to_iterator(splits) |
| ] |
| |
| self.assertEqual(_ROW_COUNT - 1, len(payloads)) |
| self.assertEqual( |
| [bytes([index]) * 1024 for index in range(_ROW_COUNT) if index != 1], |
| payloads, |
| ) |
| |
| def test_hidden_auth_field_preserves_file_io_for_blob_descriptors(self): |
| table = self._create_table( |
| "hidden_auth_field_blob_descriptor", |
| extra_options={"blob-as-descriptor": "true"}, |
| ) |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload"]) |
| auth_result = TableQueryAuthResult( |
| filter=_RejectScoreOneAuthResult.filter, |
| column_masking=None, |
| ) |
| splits = [ |
| QueryAuthSplit(split, auth_result) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| payloads = [ |
| row.get_blob(1).to_data() |
| for row in read_builder.new_read().to_iterator(splits) |
| ] |
| |
| self.assertEqual( |
| [bytes([index]) * 1024 for index in range(_ROW_COUNT) if index != 1], |
| payloads, |
| ) |
| |
| def test_auth_masking_preserves_file_io_for_blob_descriptors(self): |
| table = self._create_table( |
| "auth_masking_blob_descriptor", |
| extra_options={"blob-as-descriptor": "true"}, |
| ) |
| read_builder = table.new_read_builder().with_projection( |
| ["sample_id", "payload"]) |
| auth_result = TableQueryAuthResult( |
| filter=None, |
| column_masking={"sample_id": json.dumps({"name": "NULL"})}, |
| ) |
| splits = [ |
| QueryAuthSplit(split, auth_result) |
| for split in read_builder.new_scan().plan().splits() |
| ] |
| |
| rows = [ |
| (row.get_field(0), row.get_blob(1).to_data()) |
| for row in read_builder.new_read().to_iterator(splits) |
| ] |
| |
| self.assertEqual([None] * _ROW_COUNT, [row[0] for row in rows]) |
| self.assertEqual( |
| [bytes([index]) * 1024 for index in range(_ROW_COUNT)], |
| [row[1] for row in rows], |
| ) |
| |
| def test_preserves_null_payloads_after_filtering(self): |
| payloads = [ |
| None if index == 1 else bytes([index]) * 1024 |
| for index in range(_ROW_COUNT) |
| ] |
| table = self._create_table("defer_null", payloads=payloads) |
| predicate = table.new_read_builder().new_predicate_builder().less_than( |
| "score", 4) |
| |
| result, counting_file_io = self._read(table, predicate) |
| |
| self.assertEqual(4, result.num_rows) |
| self.assertEqual(3, counting_file_io.blobs_fetched) |
| self.assertEqual(payloads[:4], result.column("payload").to_pylist()) |
| |
| def test_blob_predicate_keeps_eager_resolution(self): |
| table = self._create_table("defer_blob_predicate") |
| expected_payload = bytes([3]) * 1024 |
| predicate = table.new_read_builder().new_predicate_builder().equal( |
| "payload", expected_payload) |
| |
| result, counting_file_io = self._read( |
| table, predicate, blob_parallelism=4) |
| |
| self.assertEqual(1, result.num_rows) |
| self.assertEqual([expected_payload], result.column("payload").to_pylist()) |
| self.assertEqual(_ROW_COUNT, counting_file_io.blobs_fetched) |
| |
| def test_defers_payloads_for_blob_fallback_reader(self): |
| table = self._create_table("defer_fallback") |
| update_builder = table.new_batch_write_builder() |
| table_update = update_builder.new_update().with_update_type(["payload"]) |
| updated_payload = b"updated-payload" |
| update_messages = table_update.update_by_arrow_with_row_id(pa.table({ |
| "_ROW_ID": pa.array([3], type=pa.int64()), |
| "payload": pa.array([updated_payload], type=pa.large_binary()), |
| })) |
| update_builder.new_commit().commit(update_messages) |
| |
| predicate = table.new_read_builder().new_predicate_builder().less_than( |
| "score", 5) |
| result, counting_file_io = self._read(table, predicate) |
| |
| self.assertEqual(5, result.num_rows) |
| self.assertEqual(5, counting_file_io.blobs_fetched) |
| payload_by_score = dict(zip( |
| result.column("score").to_pylist(), |
| result.column("payload").to_pylist(), |
| )) |
| self.assertEqual( |
| updated_payload, |
| payload_by_score[3], |
| ) |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |