| # | |
| # 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. | |
| # | |
| """``PTransform`` for reading from and writing to Web APIs.""" | |
| import abc | |
| import concurrent.futures | |
| import contextlib | |
| import enum | |
| import json | |
| import logging | |
| import sys | |
| import time | |
| from datetime import timedelta | |
| from typing import Any | |
| from typing import Generic | |
| from typing import Mapping | |
| from typing import Optional | |
| from typing import TypeVar | |
| from typing import Union | |
| from google.api_core.exceptions import TooManyRequests | |
| import apache_beam as beam | |
| from apache_beam import pvalue | |
| from apache_beam.coders import coders | |
| from apache_beam.io.components.adaptive_throttler import AdaptiveThrottler | |
| from apache_beam.metrics import Metrics | |
| from apache_beam.transforms.util import BatchElements | |
| from apache_beam.utils import retry | |
| try: | |
| import redis | |
| except ImportError: | |
| redis = None | |
| RequestT = TypeVar('RequestT') | |
| ResponseT = TypeVar('ResponseT') | |
| # DEFAULT_TIMEOUT_SECS represents the time interval for completing the request | |
| # with external source. | |
| DEFAULT_TIMEOUT_SECS = 30 | |
| # DEFAULT_CACHE_ENTRY_TTL_SEC represents the total time-to-live | |
| # for cache record. | |
| DEFAULT_CACHE_ENTRY_TTL_SEC = 24 * 60 * 60 | |
| MSEC_TO_SEC = 1000 | |
| _LOGGER = logging.getLogger(__name__) | |
| __all__ = [ | |
| 'RequestResponseIO', | |
| 'ExponentialBackOffRepeater', | |
| 'DefaultThrottler', | |
| 'NoOpsRepeater', | |
| 'RedisCache', | |
| ] | |
| class UserCodeExecutionException(Exception): | |
| """Base class for errors related to calling Web APIs.""" | |
| class UserCodeQuotaException(UserCodeExecutionException): | |
| """Extends ``UserCodeExecutionException`` to signal specifically that | |
| the Web API client encountered a Quota or API overuse related error. | |
| """ | |
| class UserCodeTimeoutException(UserCodeExecutionException): | |
| """Extends ``UserCodeExecutionException`` to signal a user code timeout.""" | |
| def retry_on_exception(exception: Exception): | |
| """retry on exceptions caused by unavailability of the remote server.""" | |
| return isinstance( | |
| exception, | |
| (TooManyRequests, UserCodeTimeoutException, UserCodeQuotaException)) | |
| class _MetricsCollector: | |
| """A metrics collector that tracks RequestResponseIO related usage.""" | |
| def __init__(self, namespace: str): | |
| """ | |
| Args: | |
| namespace: Namespace for the metrics. | |
| """ | |
| self.requests = Metrics.counter(namespace, 'requests') | |
| self.responses = Metrics.counter(namespace, 'responses') | |
| self.failures = Metrics.counter(namespace, 'failures') | |
| self.throttled_requests = Metrics.counter(namespace, 'throttled_requests') | |
| self.throttled_secs = Metrics.counter( | |
| namespace, 'cumulativeThrottlingSeconds') | |
| self.timeout_requests = Metrics.counter(namespace, 'requests_timed_out') | |
| self.call_counter = Metrics.counter(namespace, 'call_invocations') | |
| self.setup_counter = Metrics.counter(namespace, 'setup_counter') | |
| self.teardown_counter = Metrics.counter(namespace, 'teardown_counter') | |
| self.backoff_counter = Metrics.counter(namespace, 'backoff_counter') | |
| self.sleeper_counter = Metrics.counter(namespace, 'sleeper_counter') | |
| self.should_backoff_counter = Metrics.counter( | |
| namespace, 'should_backoff_counter') | |
| class Caller(contextlib.AbstractContextManager, | |
| abc.ABC, | |
| Generic[RequestT, ResponseT]): | |
| """Interface for user custom code intended for API calls. | |
| For setup and teardown of clients when applicable, implement the | |
| ``__enter__`` and ``__exit__`` methods respectively.""" | |
| @abc.abstractmethod | |
| def __call__(self, request: RequestT, *args, **kwargs) -> ResponseT: | |
| """Calls a Web API with the ``RequestT`` and returns a | |
| ``ResponseT``. ``RequestResponseIO`` expects implementations of the | |
| ``__call__`` method to throw either a ``UserCodeExecutionException``, | |
| ``UserCodeQuotaException``, or ``UserCodeTimeoutException``. | |
| """ | |
| pass | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| return None | |
| def get_cache_key(self, request: RequestT) -> str: | |
| """Returns the request to be cached. | |
| This is how the response will be looked up in the cache as well. | |
| By default, entire request is cached as the key for the cache. | |
| Implement this method to override the key for the cache. | |
| For example, in `BigTableEnrichmentHandler`, the row key for the element | |
| is returned here. | |
| """ | |
| return "" | |
| def batch_elements_kwargs(self) -> Mapping[str, Any]: | |
| """Returns a kwargs suitable for `beam.BatchElements`.""" | |
| return {} | |
| class ShouldBackOff(abc.ABC): | |
| """ | |
| Provides mechanism to apply adaptive throttling. | |
| """ | |
| pass | |
| class Repeater(abc.ABC): | |
| """Provides mechanism to repeat requests for a | |
| configurable condition.""" | |
| @abc.abstractmethod | |
| def repeat( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| request: RequestT, | |
| timeout: float, | |
| metrics_collector: Optional[_MetricsCollector]) -> ResponseT: | |
| """Implements a repeater strategy for RequestResponseIO when a repeater | |
| is enabled. | |
| Args: | |
| caller: a `~apache_beam.io.requestresponse.Caller` object that | |
| calls the API. | |
| request: input request to repeat. | |
| timeout: time to wait for the request to complete. | |
| metrics_collector: (Optional) a | |
| `~apache_beam.io.requestresponse._MetricsCollector` object | |
| to collect the metrics for RequestResponseIO. | |
| """ | |
| pass | |
| def _execute_request( | |
| caller: Caller[RequestT, ResponseT], | |
| request: RequestT, | |
| timeout: float, | |
| metrics_collector: Optional[_MetricsCollector] = None) -> ResponseT: | |
| with concurrent.futures.ThreadPoolExecutor() as executor: | |
| future = executor.submit(caller, request) | |
| try: | |
| return future.result(timeout=timeout) | |
| except TooManyRequests as e: | |
| _LOGGER.info( | |
| 'request could not be completed. got code %i from the service.', | |
| e.code) | |
| raise e | |
| except concurrent.futures.TimeoutError: | |
| if metrics_collector: | |
| metrics_collector.timeout_requests.inc(1) | |
| raise UserCodeTimeoutException( | |
| f'Timeout {timeout} exceeded ' | |
| f'while completing request: {request}') | |
| except RuntimeError: | |
| if metrics_collector: | |
| metrics_collector.failures.inc(1) | |
| raise UserCodeExecutionException('could not complete request') | |
| class ExponentialBackOffRepeater(Repeater): | |
| """Configure exponential backoff retry strategy. | |
| It retries for exceptions due to the remote service such as | |
| TooManyRequests (HTTP 429), UserCodeTimeoutException, UserCodeQuotaException. | |
| It utilizes the decorator | |
| :func:`apache_beam.utils.retry.with_exponential_backoff`. | |
| """ | |
| def __init__(self): | |
| pass | |
| @retry.with_exponential_backoff( | |
| num_retries=2, retry_filter=retry_on_exception) | |
| def repeat( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| request: RequestT, | |
| timeout: float, | |
| metrics_collector: Optional[_MetricsCollector] = None) -> ResponseT: | |
| """repeat method is called from the RequestResponseIO when | |
| a repeater is enabled. | |
| Args: | |
| caller: a `~apache_beam.io.requestresponse.Caller` object that | |
| calls the API. | |
| request: input request to repeat. | |
| timeout: time to wait for the request to complete. | |
| metrics_collector: (Optional) a | |
| `~apache_beam.io.requestresponse._MetricsCollector` object to | |
| collect the metrics for RequestResponseIO. | |
| """ | |
| return _execute_request(caller, request, timeout, metrics_collector) | |
| class NoOpsRepeater(Repeater): | |
| """Executes a request just once irrespective of any exception. | |
| """ | |
| def repeat( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| request: RequestT, | |
| timeout: float, | |
| metrics_collector: Optional[_MetricsCollector]) -> ResponseT: | |
| return _execute_request(caller, request, timeout, metrics_collector) | |
| class PreCallThrottler(abc.ABC): | |
| """Provides a throttle mechanism before sending request.""" | |
| pass | |
| class DefaultThrottler(PreCallThrottler): | |
| """Default throttler that uses | |
| :class:`apache_beam.io.components.adaptive_throttler.AdaptiveThrottler` | |
| Args: | |
| window_ms (int): length of history to consider, in ms, to set throttling. | |
| bucket_ms (int): granularity of time buckets that we store data in, in ms. | |
| overload_ratio (float): the target ratio between requests sent and | |
| successful requests. This is "K" in the formula in | |
| https://landing.google.com/sre/book/chapters/handling-overload.html. | |
| delay_secs (int): minimum number of seconds to throttle a request. | |
| """ | |
| def __init__( | |
| self, | |
| window_ms: int = 1, | |
| bucket_ms: int = 1, | |
| overload_ratio: float = 2, | |
| delay_secs: int = 5): | |
| self.throttler = AdaptiveThrottler( | |
| window_ms=window_ms, bucket_ms=bucket_ms, overload_ratio=overload_ratio) | |
| self.delay_secs = delay_secs | |
| class _FilterCacheReadFn(beam.DoFn): | |
| """A `DoFn` that partitions cache reads. | |
| It emits to main output for successful cache read requests or | |
| to the tagged output - `cache_misses` - otherwise.""" | |
| def process(self, element: tuple[RequestT, ResponseT], *args, **kwargs): | |
| if not element[1]: | |
| yield pvalue.TaggedOutput('cache_misses', element[0]) | |
| else: | |
| yield element | |
| class _Call(beam.PTransform[beam.PCollection[RequestT], | |
| beam.PCollection[ResponseT]]): | |
| """(Internal-only) PTransform that invokes a remote function on each element | |
| of the input PCollection. | |
| This PTransform uses a `Caller` object to invoke the actual API calls, | |
| and uses ``__enter__`` and ``__exit__`` to manage setup and teardown of | |
| clients when applicable. Additionally, a timeout value is specified to | |
| regulate the duration of each call, defaults to 30 seconds. | |
| Args: | |
| caller: a `Caller` object that invokes API call. | |
| timeout (float): timeout value in seconds to wait for response from API. | |
| should_backoff: (Optional) provides methods for backoff. | |
| repeater: (Optional) provides methods to repeat requests to API. | |
| throttler: (Optional) provides methods to pre-throttle a request. | |
| """ | |
| def __init__( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| timeout: Optional[float] = DEFAULT_TIMEOUT_SECS, | |
| should_backoff: Optional[ShouldBackOff] = None, | |
| repeater: Repeater = None, | |
| throttler: PreCallThrottler = None, | |
| ): | |
| self._caller = caller | |
| self._timeout = timeout | |
| self._should_backoff = should_backoff | |
| self._repeater = repeater | |
| self._throttler = throttler | |
| def expand( | |
| self, | |
| requests: beam.PCollection[RequestT]) -> beam.PCollection[ResponseT]: | |
| return requests | beam.ParDo( | |
| _CallDoFn(self._caller, self._timeout, self._repeater, self._throttler)) | |
| class _CallDoFn(beam.DoFn): | |
| def setup(self): | |
| self._caller.__enter__() | |
| self._metrics_collector = _MetricsCollector(self._caller.__str__()) | |
| self._metrics_collector.setup_counter.inc(1) | |
| def __init__( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| timeout: float, | |
| repeater: Repeater, | |
| throttler: PreCallThrottler): | |
| self._metrics_collector = None | |
| self._caller = caller | |
| self._timeout = timeout | |
| self._repeater = repeater | |
| self._throttler = throttler | |
| def process(self, request: RequestT, *args, **kwargs): | |
| self._metrics_collector.requests.inc(1) | |
| is_throttled_request = False | |
| if self._throttler is not None: | |
| while self._throttler.throttler.throttle_request(time.time() * | |
| MSEC_TO_SEC): | |
| _LOGGER.info( | |
| "Delaying request for %d seconds" % self._throttler.delay_secs) | |
| time.sleep(self._throttler.delay_secs) | |
| self._metrics_collector.throttled_secs.inc(self._throttler.delay_secs) | |
| is_throttled_request = True | |
| if is_throttled_request: | |
| self._metrics_collector.throttled_requests.inc(1) | |
| try: | |
| req_time = time.time() | |
| response = self._repeater.repeat( | |
| self._caller, request, self._timeout, self._metrics_collector) | |
| self._metrics_collector.responses.inc(1) | |
| if self._throttler is not None: | |
| self._throttler.throttler.successful_request(req_time * MSEC_TO_SEC) | |
| yield response | |
| except Exception as e: | |
| raise e | |
| def teardown(self): | |
| self._metrics_collector.teardown_counter.inc(1) | |
| self._caller.__exit__(*sys.exc_info()) | |
| class Cache(abc.ABC): | |
| """Base Cache class for | |
| :class:`apache_beam.io.requestresponse.RequestResponseIO`. | |
| For adding cache support to RequestResponseIO, implement this class. | |
| """ | |
| @abc.abstractmethod | |
| def get_read(self): | |
| """returns a PTransform that reads from the cache.""" | |
| pass | |
| @abc.abstractmethod | |
| def get_write(self): | |
| """returns a PTransform that writes to the cache.""" | |
| pass | |
| @property | |
| @abc.abstractmethod | |
| def request_coder(self): | |
| """request coder to use with Cache.""" | |
| pass | |
| @request_coder.setter | |
| @abc.abstractmethod | |
| def request_coder(self, request_coder: coders.Coder): | |
| """sets the request coder to use with Cache.""" | |
| pass | |
| @property | |
| @abc.abstractmethod | |
| def source_caller(self): | |
| """Actual caller that is using the cache.""" | |
| pass | |
| @source_caller.setter | |
| @abc.abstractmethod | |
| def source_caller(self, caller: Caller): | |
| """Sets the source caller for | |
| :class:`apache_beam.io.requestresponse.RequestResponseIO` to pull | |
| cache request key from respective callers.""" | |
| pass | |
| class _RedisMode(enum.Enum): | |
| """ | |
| Mode of operation for redis cache when using | |
| `~apache_beam.io.requestresponse._RedisCaller`. | |
| """ | |
| READ = 0 | |
| WRITE = 1 | |
| class _RedisCaller(Caller): | |
| """An implementation of | |
| `~apache_beam.io.requestresponse.Caller` for Redis client. | |
| It provides the functionality for making requests to Redis server using | |
| :class:`apache_beam.io.requestresponse.RequestResponseIO`. | |
| """ | |
| def __init__( | |
| self, | |
| host: str, | |
| port: int, | |
| time_to_live: Union[int, timedelta], | |
| *, | |
| request_coder: Optional[coders.Coder], | |
| response_coder: Optional[coders.Coder], | |
| kwargs: Optional[dict[str, Any]] = None, | |
| source_caller: Optional[Caller] = None, | |
| mode: _RedisMode, | |
| ): | |
| """ | |
| Args: | |
| host (str): The hostname or IP address of the Redis server. | |
| port (int): The port number of the Redis server. | |
| time_to_live: `(Union[int, timedelta])` The time-to-live (TTL) for | |
| records stored in Redis. Provide an integer (in seconds) or a | |
| `datetime.timedelta` object. | |
| request_coder: (Optional[`coders.Coder`]) coder for requests stored | |
| in Redis. | |
| response_coder: (Optional[`coders.Coder`]) coder for decoding responses | |
| received from Redis. | |
| kwargs: Optional(Dict[str, Any]) additional keyword arguments that | |
| are required to connect to your redis server. Same as `redis.Redis()`. | |
| source_caller: (Optional[`Caller`]): The source caller using this Redis | |
| cache in case of fetching the cache request to store in Redis. | |
| mode: `_RedisMode` An enum type specifying the operational mode of | |
| the `_RedisCaller`. | |
| """ | |
| self.host, self.port = host, port | |
| self.time_to_live = time_to_live | |
| self.request_coder = request_coder | |
| self.response_coder = response_coder | |
| self.kwargs = kwargs | |
| self.source_caller = source_caller | |
| self.mode = mode | |
| def __enter__(self): | |
| self.client = redis.Redis(self.host, self.port, **self.kwargs) | |
| def _read_cache(self, element): | |
| cache_request = self.source_caller.get_cache_key(element) | |
| # check if the caller is a enrichment handler. EnrichmentHandler | |
| # provides the request format for cache. | |
| if cache_request: | |
| encoded_request = self.request_coder.encode(cache_request) | |
| else: | |
| encoded_request = self.request_coder.encode(element) | |
| encoded_response = self.client.get(encoded_request) | |
| if not encoded_response: | |
| # no cache entry present for this request. | |
| return element, None | |
| if self.response_coder is None: | |
| try: | |
| response_dict = json.loads(encoded_response.decode('utf-8')) | |
| response = beam.Row(**response_dict) | |
| except Exception: | |
| _LOGGER.warning( | |
| 'cannot decode response from redis cache for %s.' % element) | |
| return element, None | |
| else: | |
| response = self.response_coder.decode(encoded_response) | |
| return element, response | |
| def _write_cache(self, element): | |
| cache_request = self.source_caller.get_cache_key(element[0]) | |
| if cache_request: | |
| encoded_request = self.request_coder.encode(cache_request) | |
| else: | |
| encoded_request = self.request_coder.encode(element[0]) | |
| if self.response_coder is None: | |
| try: | |
| encoded_response = json.dumps(element[1]._asdict()).encode('utf-8') | |
| except Exception: | |
| _LOGGER.warning( | |
| 'cannot encode response %s for %s to store in ' | |
| 'redis cache.' % (element[1], element[0])) | |
| return element | |
| else: | |
| encoded_response = self.response_coder.encode(element[1]) | |
| # Write to cache with TTL. Set nx to True to prevent overwriting for the | |
| # same key. | |
| self.client.set( | |
| encoded_request, encoded_response, self.time_to_live, nx=True) | |
| return element | |
| def __call__(self, element, *args, **kwargs): | |
| if self.mode == _RedisMode.READ: | |
| if isinstance(element, list): | |
| responses = [self._read_cache(e) for e in element] | |
| return responses | |
| else: | |
| return self._read_cache(element) | |
| else: | |
| if isinstance(element, list): | |
| responses = [self._write_cache(e) for e in element] | |
| return responses | |
| else: | |
| return self._write_cache(element) | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| self.client.close() | |
| class _ReadFromRedis(beam.PTransform[beam.PCollection[RequestT], | |
| beam.PCollection[ResponseT]]): | |
| """A `PTransform` that performs Redis cache read.""" | |
| def __init__( | |
| self, | |
| host: str, | |
| port: int, | |
| time_to_live: Union[int, timedelta], | |
| *, | |
| kwargs: Optional[dict[str, Any]] = None, | |
| request_coder: Optional[coders.Coder], | |
| response_coder: Optional[coders.Coder], | |
| source_caller: Optional[Caller[RequestT, ResponseT]] = None, | |
| ): | |
| """ | |
| Args: | |
| host (str): The hostname or IP address of the Redis server. | |
| port (int): The port number of the Redis server. | |
| time_to_live: `(Union[int, timedelta])` The time-to-live (TTL) for | |
| records stored in Redis. Provide an integer (in seconds) or a | |
| `datetime.timedelta` object. | |
| kwargs: Optional(Dict[str, Any]) additional keyword arguments that | |
| are required to connect to your redis server. Same as `redis.Redis()`. | |
| request_coder: (Optional[`coders.Coder`]) coder for requests stored | |
| in Redis. | |
| response_coder: (Optional[`coders.Coder`]) coder for decoding responses | |
| received from Redis. | |
| source_caller: (Optional[`Caller`]): The source caller using this Redis | |
| cache in case of fetching the cache request to store in Redis. | |
| """ | |
| self.request_coder = request_coder | |
| self.response_coder = response_coder | |
| self.redis_caller = _RedisCaller( | |
| host, | |
| port, | |
| time_to_live, | |
| request_coder=self.request_coder, | |
| response_coder=self.response_coder, | |
| kwargs=kwargs, | |
| source_caller=source_caller, | |
| mode=_RedisMode.READ) | |
| def expand( | |
| self, | |
| requests: beam.PCollection[RequestT]) -> beam.PCollection[ResponseT]: | |
| return requests | RequestResponseIO(self.redis_caller) | |
| class _WriteToRedis(beam.PTransform[beam.PCollection[tuple[RequestT, | |
| ResponseT]], | |
| beam.PCollection[ResponseT]]): | |
| """A `PTransfrom` that performs write to Redis cache.""" | |
| def __init__( | |
| self, | |
| host: str, | |
| port: int, | |
| time_to_live: Union[int, timedelta], | |
| *, | |
| kwargs: Optional[dict[str, Any]] = None, | |
| request_coder: Optional[coders.Coder], | |
| response_coder: Optional[coders.Coder], | |
| source_caller: Optional[Caller[RequestT, ResponseT]] = None, | |
| ): | |
| """ | |
| Args: | |
| host (str): The hostname or IP address of the Redis server. | |
| port (int): The port number of the Redis server. | |
| time_to_live: `(Union[int, timedelta])` The time-to-live (TTL) for | |
| records stored in Redis. Provide an integer (in seconds) or a | |
| `datetime.timedelta` object. | |
| kwargs: Optional(Dict[str, Any]) additional keyword arguments that | |
| are required to connect to your redis server. Same as `redis.Redis()`. | |
| request_coder: (Optional[`coders.Coder`]) coder for requests stored | |
| in Redis. | |
| response_coder: (Optional[`coders.Coder`]) coder for decoding responses | |
| received from Redis. | |
| source_caller: (Optional[`Caller`]): The source caller using this Redis | |
| cache in case of fetching the cache request to store in Redis. | |
| """ | |
| self.request_coder = request_coder | |
| self.response_coder = response_coder | |
| self.redis_caller = _RedisCaller( | |
| host, | |
| port, | |
| time_to_live, | |
| request_coder=self.request_coder, | |
| response_coder=self.response_coder, | |
| kwargs=kwargs, | |
| source_caller=source_caller, | |
| mode=_RedisMode.WRITE) | |
| def expand( | |
| self, elements: beam.PCollection[tuple[RequestT, ResponseT]] | |
| ) -> beam.PCollection[ResponseT]: | |
| return elements | RequestResponseIO(self.redis_caller) | |
| def ensure_coders_exist(request_coder): | |
| """checks if the coder exists to encode the request for caching.""" | |
| if not request_coder: | |
| raise ValueError( | |
| 'need request coder to be able to use ' | |
| 'Cache with RequestResponseIO.') | |
| class RedisCache(Cache): | |
| """Configure cache using Redis for | |
| :class:`apache_beam.io.requestresponse.RequestResponseIO`.""" | |
| def __init__( | |
| self, | |
| host: str, | |
| port: int, | |
| time_to_live: Union[int, timedelta] = DEFAULT_CACHE_ENTRY_TTL_SEC, | |
| *, | |
| request_coder: Optional[coders.Coder] = None, | |
| response_coder: Optional[coders.Coder] = None, | |
| **kwargs, | |
| ): | |
| """ | |
| Args: | |
| host (str): The hostname or IP address of the Redis server. | |
| port (int): The port number of the Redis server. | |
| time_to_live: `(Union[int, timedelta])` The time-to-live (TTL) for | |
| records stored in Redis. Provide an integer (in seconds) or a | |
| `datetime.timedelta` object. | |
| request_coder: (Optional[`coders.Coder`]) coder for encoding requests. | |
| response_coder: (Optional[`coders.Coder`]) coder for decoding responses | |
| received from Redis. | |
| kwargs: Optional additional keyword arguments that | |
| are required to connect to your redis server. Same as `redis.Redis()`. | |
| """ | |
| self._host = host | |
| self._port = port | |
| self._time_to_live = time_to_live | |
| self._request_coder = request_coder | |
| self._response_coder = response_coder | |
| self._kwargs = kwargs if kwargs else {} | |
| self._source_caller = None | |
| if redis is None: | |
| raise ImportError( | |
| 'Failed to import redis. You can ensure it is ' | |
| 'installed by installing the redis beam extra') | |
| def get_read(self): | |
| """get_read returns a PTransform for reading from the cache.""" | |
| ensure_coders_exist(self._request_coder) | |
| return _ReadFromRedis( | |
| self._host, | |
| self._port, | |
| time_to_live=self._time_to_live, | |
| kwargs=self._kwargs, | |
| request_coder=self._request_coder, | |
| response_coder=self._response_coder, | |
| source_caller=self._source_caller) | |
| def get_write(self): | |
| """returns a PTransform for writing to the cache.""" | |
| ensure_coders_exist(self._request_coder) | |
| return _WriteToRedis( | |
| self._host, | |
| self._port, | |
| time_to_live=self._time_to_live, | |
| kwargs=self._kwargs, | |
| request_coder=self._request_coder, | |
| response_coder=self._response_coder, | |
| source_caller=self._source_caller) | |
| @property | |
| def source_caller(self): | |
| return self._source_caller | |
| @source_caller.setter | |
| def source_caller(self, source_caller: Caller): | |
| self._source_caller = source_caller | |
| @property | |
| def request_coder(self): | |
| return self._request_coder | |
| @request_coder.setter | |
| def request_coder(self, request_coder: coders.Coder): | |
| self._request_coder = request_coder | |
| class FlattenBatch(beam.DoFn): | |
| """Flatten a batched PCollection.""" | |
| def process(self, elements, *args, **kwargs): | |
| for element in elements: | |
| yield element | |
| class RequestResponseIO(beam.PTransform[beam.PCollection[RequestT], | |
| beam.PCollection[ResponseT]]): | |
| """A :class:`RequestResponseIO` transform to read and write to APIs. | |
| Processes an input :class:`~apache_beam.pvalue.PCollection` of requests | |
| by making a call to the API as defined in `Caller`'s `__call__` method | |
| and returns a :class:`~apache_beam.pvalue.PCollection` of responses. | |
| """ | |
| def __init__( | |
| self, | |
| caller: Caller[RequestT, ResponseT], | |
| timeout: Optional[float] = DEFAULT_TIMEOUT_SECS, | |
| should_backoff: Optional[ShouldBackOff] = None, | |
| repeater: Repeater = ExponentialBackOffRepeater(), | |
| cache: Optional[Cache] = None, | |
| throttler: PreCallThrottler = DefaultThrottler(), | |
| ): | |
| """ | |
| Instantiates a RequestResponseIO transform. | |
| Args: | |
| caller: an implementation of | |
| `Caller` object that makes call to the API. | |
| timeout (float): timeout value in seconds to wait for response from API. | |
| should_backoff: (Optional) provides methods for backoff. | |
| repeater: provides method to repeat failed requests to API due to service | |
| errors. Defaults to | |
| :class:`apache_beam.io.requestresponse.ExponentialBackOffRepeater` to | |
| repeat requests with exponential backoff. | |
| cache: (Optional) a `~apache_beam.io.requestresponse.Cache` object | |
| to use the appropriate cache. | |
| throttler: provides methods to pre-throttle a request. Defaults to | |
| :class:`apache_beam.io.requestresponse.DefaultThrottler` for | |
| client-side adaptive throttling using | |
| :class:`apache_beam.io.components.adaptive_throttler.AdaptiveThrottler` | |
| """ | |
| self._caller = caller | |
| self._timeout = timeout | |
| self._should_backoff = should_backoff | |
| if repeater: | |
| self._repeater = repeater | |
| else: | |
| self._repeater = NoOpsRepeater() | |
| self._cache = cache | |
| self._throttler = throttler | |
| self._batching_kwargs = self._caller.batch_elements_kwargs() | |
| def expand( | |
| self, | |
| requests: beam.PCollection[RequestT]) -> beam.PCollection[ResponseT]: | |
| # TODO(riteshghorse): handle Throttle PTransforms when available. | |
| if self._cache: | |
| self._cache.source_caller = self._caller | |
| inputs = requests | |
| if self._cache: | |
| # read from cache. | |
| outputs = inputs | self._cache.get_read() | |
| # filter responses that are None and send them to the Call transform | |
| # to fetch a value from external service. | |
| cached_responses, inputs = (outputs | |
| | beam.ParDo(_FilterCacheReadFn() | |
| ).with_outputs( | |
| 'cache_misses', main='cached_responses')) | |
| # Batch elements if batching is enabled. | |
| if self._batching_kwargs: | |
| inputs = inputs | BatchElements(**self._batching_kwargs) | |
| if isinstance(self._throttler, DefaultThrottler): | |
| # DefaultThrottler applies throttling in the DoFn of | |
| # Call PTransform. | |
| responses = ( | |
| inputs | |
| | _Call( | |
| caller=self._caller, | |
| timeout=self._timeout, | |
| should_backoff=self._should_backoff, | |
| repeater=self._repeater, | |
| throttler=self._throttler)) | |
| else: | |
| # No throttling mechanism. The requests are made to the external source | |
| # as they come. | |
| responses = ( | |
| inputs | |
| | _Call( | |
| caller=self._caller, | |
| timeout=self._timeout, | |
| should_backoff=self._should_backoff, | |
| repeater=self._repeater)) | |
| # if batching is enabled then handle accordingly. | |
| if self._batching_kwargs: | |
| responses = responses | "FlattenBatch" >> beam.ParDo(FlattenBatch()) | |
| if self._cache: | |
| # write to cache. | |
| _ = responses | self._cache.get_write() | |
| return (cached_responses, responses) | beam.Flatten() | |
| return responses |