| # | |
| # 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 base64 | |
| import logging | |
| import sys | |
| import time | |
| import typing | |
| import unittest | |
| from dataclasses import dataclass | |
| from typing import Union | |
| import pytest | |
| import urllib3 | |
| import apache_beam as beam | |
| from apache_beam.coders import coders | |
| from apache_beam.options.pipeline_options import PipelineOptions | |
| from apache_beam.testing.test_pipeline import TestPipeline | |
| # pylint: disable=ungrouped-imports | |
| try: | |
| from testcontainers.redis import RedisContainer | |
| from apache_beam.io.requestresponse import Caller | |
| from apache_beam.io.requestresponse import RedisCache | |
| from apache_beam.io.requestresponse import RequestResponseIO | |
| from apache_beam.io.requestresponse import UserCodeExecutionException | |
| from apache_beam.io.requestresponse import UserCodeQuotaException | |
| except ImportError: | |
| raise unittest.SkipTest('RequestResponseIO dependencies are not installed.') | |
| _HTTP_PATH = '/v1/echo' | |
| _PAYLOAD = base64.b64encode(bytes('payload', 'utf-8')) | |
| _HTTP_ENDPOINT_ADDRESS_FLAG = '--httpEndpointAddress' | |
| _LOGGER = logging.getLogger(__name__) | |
| class EchoITOptions(PipelineOptions): | |
| """Shared options for running integration tests on a deployed | |
| ``EchoServiceGrpc`` See https://github.com/apache/beam/tree/master/.test | |
| -infra/mock-apis#integration for details on how to acquire values | |
| required by ``EchoITOptions``. | |
| """ | |
| @classmethod | |
| def _add_argparse_args(cls, parser) -> None: | |
| parser.add_argument( | |
| _HTTP_ENDPOINT_ADDRESS_FLAG, | |
| default='http://10.138.0.32:8080', | |
| dest='http_endpoint_address', | |
| help='The HTTP address of the Echo API endpoint; must being with ' | |
| 'http(s)://') | |
| parser.add_argument( | |
| '--neverExceedQuotaId', | |
| default='echo-should-never-exceed-quota', | |
| dest='never_exceed_quota_id', | |
| help='The ID for an allocated quota that should never exceed.') | |
| parser.add_argument( | |
| '--shouldExceedQuotaId', | |
| default='echo-should-exceed-quota', | |
| dest='should_exceed_quota_id', | |
| help='The ID for an allocated quota that should exceed.') | |
| @dataclass | |
| class EchoResponse: | |
| id: str | |
| payload: bytes | |
| # TODO(riteshghorse,damondouglas) replace Echo(Request|Response) with proto | |
| # generated classes from .test-infra/mock-apis: | |
| class Request(typing.NamedTuple): | |
| id: str | |
| payload: bytes | |
| class EchoHTTPCaller(Caller[Request, EchoResponse]): | |
| """Implements ``Caller`` to call the ``EchoServiceGrpc``'s HTTP handler. | |
| The purpose of ``EchoHTTPCaller`` is to support integration tests. | |
| """ | |
| def __init__(self, url: str): | |
| self.url = url + _HTTP_PATH | |
| def __call__(self, request: Request, *args, **kwargs) -> EchoResponse: | |
| """Overrides ``Caller``'s call method invoking the | |
| ``EchoServiceGrpc``'s HTTP handler with an ``EchoRequest``, returning | |
| either a successful ``EchoResponse`` or throwing either a | |
| ``UserCodeExecutionException``, ``UserCodeTimeoutException``, | |
| or a ``UserCodeQuotaException``. | |
| """ | |
| try: | |
| http = urllib3.PoolManager() | |
| resp = http.request( | |
| "POST", | |
| self.url, | |
| json={ | |
| "id": request.id, "payload": str(request.payload, 'utf-8') | |
| }, | |
| retries=False) | |
| if resp.status < 300: | |
| resp_body = resp.json() | |
| resp_id = resp_body['id'] | |
| payload = resp_body['payload'] | |
| return EchoResponse(id=resp_id, payload=bytes(payload, 'utf-8')) | |
| if resp.status == 429: # Too Many Requests | |
| raise UserCodeQuotaException(resp.reason) | |
| else: | |
| raise UserCodeExecutionException(resp.status, resp.reason, request) | |
| except urllib3.exceptions.HTTPError as e: | |
| raise UserCodeExecutionException(e) | |
| class ValidateResponse(beam.DoFn): | |
| """Validates response received from Mock API server.""" | |
| def process(self, element, *args, **kwargs): | |
| if (element.id != 'echo-should-never-exceed-quota' or | |
| element.payload != _PAYLOAD): | |
| raise ValueError( | |
| 'got EchoResponse(id: %s, payload: %s), want ' | |
| 'EchoResponse(id: echo-should-never-exceed-quota, ' | |
| 'payload: %s' % (element.id, element.payload, _PAYLOAD)) | |
| @pytest.mark.uses_mock_api | |
| class EchoHTTPCallerTestIT(unittest.TestCase): | |
| options: Union[EchoITOptions, None] = None | |
| client: Union[EchoHTTPCaller, None] = None | |
| @classmethod | |
| def setUpClass(cls) -> None: | |
| cls.options = EchoITOptions() | |
| http_endpoint_address = cls.options.http_endpoint_address | |
| if not http_endpoint_address or http_endpoint_address == '': | |
| raise unittest.SkipTest(f'{_HTTP_ENDPOINT_ADDRESS_FLAG} is required.') | |
| cls.client = EchoHTTPCaller(http_endpoint_address) | |
| @classmethod | |
| def _get_client_and_options(cls) -> tuple[EchoHTTPCaller, EchoITOptions]: | |
| assert cls.options is not None | |
| assert cls.client is not None | |
| return cls.client, cls.options | |
| def test_request_response_io(self): | |
| client, options = EchoHTTPCallerTestIT._get_client_and_options() | |
| req = Request(id=options.never_exceed_quota_id, payload=_PAYLOAD) | |
| with TestPipeline(is_integration_test=True) as test_pipeline: | |
| output = ( | |
| test_pipeline | |
| | 'Create PCollection' >> beam.Create([req]) | |
| | 'RRIO Transform' >> RequestResponseIO(client) | |
| | 'Validate' >> beam.ParDo(ValidateResponse())) | |
| self.assertIsNotNone(output) | |
| class ValidateCacheResponses(beam.DoFn): | |
| """Validates that the responses are fetched from the cache.""" | |
| def process(self, element, *args, **kwargs): | |
| if not element[1] or 'cached-' not in element[1]: | |
| raise ValueError( | |
| 'responses not fetched from cache even though cache ' | |
| 'entries are present.') | |
| class ValidateCallerResponses(beam.DoFn): | |
| """Validates that the responses are fetched from the caller.""" | |
| def process(self, element, *args, **kwargs): | |
| if not element[1] or 'ACK-' not in element[1]: | |
| raise ValueError('responses not fetched from caller when they should.') | |
| class FakeCallerForCache(Caller[str, str]): | |
| def __init__(self, use_cache: bool = False): | |
| self.use_cache = use_cache | |
| def __enter__(self): | |
| pass | |
| def __call__(self, element, *args, **kwargs): | |
| if self.use_cache: | |
| return None, None | |
| return element, 'ACK-{element}' | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| pass | |
| @pytest.mark.uses_testcontainer | |
| class TestRedisCache(unittest.TestCase): | |
| def setUp(self) -> None: | |
| self.retries = 5 | |
| self._start_container() | |
| def test_rrio_cache_all_miss(self): | |
| """Cache is empty so all responses are fetched from caller.""" | |
| caller = FakeCallerForCache() | |
| req = ['redis', 'cachetools', 'memcache'] | |
| cache = RedisCache( | |
| self.host, | |
| self.port, | |
| time_to_live=30, | |
| request_coder=coders.StrUtf8Coder(), | |
| response_coder=coders.StrUtf8Coder()) | |
| with TestPipeline(is_integration_test=True) as p: | |
| _ = ( | |
| p | |
| | beam.Create(req) | |
| | RequestResponseIO(caller, cache=cache) | |
| | beam.ParDo(ValidateCallerResponses())) | |
| def test_rrio_cache_all_hit(self): | |
| """Validate that records are fetched from cache.""" | |
| caller = FakeCallerForCache() | |
| requests = ['foo', 'bar'] | |
| responses = ['cached-foo', 'cached-bar'] | |
| coder = coders.StrUtf8Coder() | |
| for i in range(len(requests)): | |
| enc_req = coder.encode(requests[i]) | |
| enc_resp = coder.encode(responses[i]) | |
| self.client.setex(enc_req, 120, enc_resp) | |
| cache = RedisCache( | |
| self.host, | |
| self.port, | |
| time_to_live=30, | |
| request_coder=coders.StrUtf8Coder(), | |
| response_coder=coders.StrUtf8Coder()) | |
| with TestPipeline(is_integration_test=True) as p: | |
| _ = ( | |
| p | |
| | beam.Create(requests) | |
| | RequestResponseIO(caller, cache=cache) | |
| | beam.ParDo(ValidateCacheResponses())) | |
| def test_rrio_cache_miss_and_hit(self): | |
| """Run two back-to-back pipelines, one with pulling the data from caller | |
| and other from the cache.""" | |
| caller = FakeCallerForCache() | |
| requests = ['beam', 'flink', 'spark'] | |
| cache = RedisCache( | |
| self.host, | |
| self.port, | |
| request_coder=coders.StrUtf8Coder(), | |
| response_coder=coders.StrUtf8Coder()) | |
| with TestPipeline(is_integration_test=True) as p: | |
| _ = ( | |
| p | |
| | beam.Create(requests) | |
| | RequestResponseIO(caller, cache=cache) | |
| | beam.ParDo(ValidateCallerResponses())) | |
| caller = FakeCallerForCache(use_cache=True) | |
| with TestPipeline(is_integration_test=True) as p: | |
| _ = ( | |
| p | |
| | beam.Create(requests) | |
| | RequestResponseIO(caller, cache=cache) | |
| | beam.ParDo(ValidateCallerResponses())) | |
| def test_rrio_no_coder_exception(self): | |
| caller = FakeCallerForCache() | |
| requests = ['beam', 'flink', 'spark'] | |
| cache = RedisCache(self.host, self.port) | |
| with self.assertRaises(ValueError): | |
| test_pipeline = beam.Pipeline() | |
| _ = ( | |
| test_pipeline | |
| | beam.Create(requests) | |
| | RequestResponseIO(caller, cache=cache)) | |
| res = test_pipeline.run() | |
| res.wait_until_finish() | |
| def tearDown(self) -> None: | |
| self.container.stop() | |
| def _start_container(self): | |
| for i in range(self.retries): | |
| try: | |
| self.container = RedisContainer(image='redis:7.2.4') | |
| self.container.start() | |
| self.host = self.container.get_container_host_ip() | |
| self.port = self.container.get_exposed_port(6379) | |
| self.client = self.container.get_client() | |
| break | |
| except Exception as e: | |
| if i == self.retries - 1: | |
| _LOGGER.error('Unable to start redis container for RRIO tests.') | |
| raise e | |
| # Add a small delay between retries to avoid rapid successive failures | |
| time.sleep(2) | |
| if __name__ == '__main__': | |
| unittest.main(argv=sys.argv[:1]) |