|  | @@ -1,50 +1,126 @@
 | 
	
		
			
				|  |  |  # TODO: Flowerbox.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +import collections
 | 
	
		
			
				|  |  | +import datetime
 | 
	
		
			
				|  |  | +import os
 | 
	
		
			
				|  |  | +import logging
 | 
	
		
			
				|  |  |  import threading
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  import grpc
 | 
	
		
			
				|  |  |  from typing import Any, Callable, Optional, Sequence, Text, Tuple, Union
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -_CHANNEL_CACHE = None
 | 
	
		
			
				|  |  | -_CHANNEL_CACHE_LOCK = threading.RLock()
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -# TODO: Evict channels.
 | 
	
		
			
				|  |  | +_LOGGER = logging.getLogger(__name__)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +_EVICTION_PERIOD_KEY = "GRPC_PYTHON_MANAGED_CHANNEL_EVICTION_SECONDS"
 | 
	
		
			
				|  |  | +if _EVICTION_PERIOD_KEY in os.environ:
 | 
	
		
			
				|  |  | +    _EVICTION_PERIOD = datetime.timedelta(seconds=float(os.environ[_EVICTION_PERIOD_KEY]))
 | 
	
		
			
				|  |  | +else:
 | 
	
		
			
				|  |  | +    _EVICTION_PERIOD = datetime.timedelta(minutes=10)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -# Eviction policy based on both channel count and time since use. Perhaps
 | 
	
		
			
				|  |  | -# OrderedDict instead?
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  def _create_channel(target: Text,
 | 
	
		
			
				|  |  |                      options: Sequence[Tuple[Text, Text]],
 | 
	
		
			
				|  |  |                      channel_credentials: Optional[grpc.ChannelCredentials],
 | 
	
		
			
				|  |  |                      compression: Optional[grpc.Compression]) -> grpc.Channel:
 | 
	
		
			
				|  |  |      if channel_credentials is None:
 | 
	
		
			
				|  |  | +        _LOGGER.info(f"Creating insecure channel with options '{options}' " +
 | 
	
		
			
				|  |  | +                       f"and compression '{compression}'")
 | 
	
		
			
				|  |  |          return grpc.insecure_channel(target,
 | 
	
		
			
				|  |  |                                       options=options,
 | 
	
		
			
				|  |  |                                       compression=compression)
 | 
	
		
			
				|  |  |      else:
 | 
	
		
			
				|  |  | +        _LOGGER.info(f"Creating secure channel with credentials '{channel_credentials}', " +
 | 
	
		
			
				|  |  | +                       f"options '{options}' and compression '{compression}'")
 | 
	
		
			
				|  |  |          return grpc.secure_channel(target,
 | 
	
		
			
				|  |  |                                     credentials=channel_credentials,
 | 
	
		
			
				|  |  |                                     options=options,
 | 
	
		
			
				|  |  |                                     compression=compression)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +class ChannelCache:
 | 
	
		
			
				|  |  | +    _singleton = None
 | 
	
		
			
				|  |  | +    _lock = threading.RLock()
 | 
	
		
			
				|  |  | +    _condition = threading.Condition(lock=_lock)
 | 
	
		
			
				|  |  | +    _eviction_ready = threading.Event()
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    def __init__(self):
 | 
	
		
			
				|  |  | +        self._mapping = collections.OrderedDict()
 | 
	
		
			
				|  |  | +        self._eviction_thread = threading.Thread(target=ChannelCache._perform_evictions, daemon=True)
 | 
	
		
			
				|  |  | +        self._eviction_thread.start()
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    @staticmethod
 | 
	
		
			
				|  |  | +    def get():
 | 
	
		
			
				|  |  | +        with ChannelCache._lock:
 | 
	
		
			
				|  |  | +            if ChannelCache._singleton is None:
 | 
	
		
			
				|  |  | +                ChannelCache._singleton = ChannelCache()
 | 
	
		
			
				|  |  | +        ChannelCache._eviction_ready.wait()
 | 
	
		
			
				|  |  | +        return ChannelCache._singleton
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    # TODO: Type annotate key.
 | 
	
		
			
				|  |  | +    def _evict_locked(self, key):
 | 
	
		
			
				|  |  | +        channel, _ = self._mapping.pop(key)
 | 
	
		
			
				|  |  | +        _LOGGER.info(f"Evicting channel {channel} with configuration {key}.")
 | 
	
		
			
				|  |  | +        channel.close()
 | 
	
		
			
				|  |  | +        del channel
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    # TODO: Refactor. Way too deeply nested.
 | 
	
		
			
				|  |  | +    @staticmethod
 | 
	
		
			
				|  |  | +    def _perform_evictions():
 | 
	
		
			
				|  |  | +        while True:
 | 
	
		
			
				|  |  | +            with ChannelCache._lock:
 | 
	
		
			
				|  |  | +                ChannelCache._eviction_ready.set()
 | 
	
		
			
				|  |  | +                if not ChannelCache._singleton._mapping:
 | 
	
		
			
				|  |  | +                    ChannelCache._condition.wait()
 | 
	
		
			
				|  |  | +                else:
 | 
	
		
			
				|  |  | +                    key, (channel, eviction_time) = next(iter(ChannelCache._singleton._mapping.items()))
 | 
	
		
			
				|  |  | +                    now = datetime.datetime.now()
 | 
	
		
			
				|  |  | +                    if eviction_time <= now:
 | 
	
		
			
				|  |  | +                        ChannelCache._singleton._evict_locked(key)
 | 
	
		
			
				|  |  | +                        continue
 | 
	
		
			
				|  |  | +                    else:
 | 
	
		
			
				|  |  | +                        time_to_eviction = (eviction_time - now).total_seconds()
 | 
	
		
			
				|  |  | +                        ChannelCache._condition.wait(timeout=time_to_eviction)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -def _get_cached_channel(target: Text,
 | 
	
		
			
				|  |  | -                        options: Sequence[Tuple[Text, Text]],
 | 
	
		
			
				|  |  | -                        channel_credentials: Optional[grpc.ChannelCredentials],
 | 
	
		
			
				|  |  | -                        compression: Optional[grpc.Compression]) -> grpc.Channel:
 | 
	
		
			
				|  |  | -    global _CHANNEL_CACHE
 | 
	
		
			
				|  |  | -    global _CHANNEL_CACHE_LOCK
 | 
	
		
			
				|  |  | -    key = (target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  | -    with _CHANNEL_CACHE_LOCK:
 | 
	
		
			
				|  |  | -        if _CHANNEL_CACHE is None:
 | 
	
		
			
				|  |  | -            _CHANNEL_CACHE = {}
 | 
	
		
			
				|  |  | -        channel = _CHANNEL_CACHE.get(key, None)
 | 
	
		
			
				|  |  | -        if channel is not None:
 | 
	
		
			
				|  |  | -            return channel
 | 
	
		
			
				|  |  | -        else:
 | 
	
		
			
				|  |  | -            channel = _create_channel(target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  | -            _CHANNEL_CACHE[key] = channel
 | 
	
		
			
				|  |  | -            return channel
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    def get_channel(self,
 | 
	
		
			
				|  |  | +                    target: Text,
 | 
	
		
			
				|  |  | +                    options: Sequence[Tuple[Text, Text]],
 | 
	
		
			
				|  |  | +                    channel_credentials: Optional[grpc.ChannelCredentials],
 | 
	
		
			
				|  |  | +                    compression: Optional[grpc.Compression]) -> grpc.Channel:
 | 
	
		
			
				|  |  | +        key = (target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  | +        with self._lock:
 | 
	
		
			
				|  |  | +            # TODO: Can the get and the pop be turned into a single operation?
 | 
	
		
			
				|  |  | +            channel_data = self._mapping.get(key, None)
 | 
	
		
			
				|  |  | +            if channel_data is not None:
 | 
	
		
			
				|  |  | +                channel = channel_data[0]
 | 
	
		
			
				|  |  | +                # # NOTE: This isn't actually necessary. The eviction thread will
 | 
	
		
			
				|  |  | +                # # always wake up because the new head of the list will, by
 | 
	
		
			
				|  |  | +                # # definition, have a later eviction time than the old head of
 | 
	
		
			
				|  |  | +                # # the list. If however, we allow for channels with heterogeneous
 | 
	
		
			
				|  |  | +                # # eviction periods, this *will* become necessary. We can imagine
 | 
	
		
			
				|  |  | +                # # this would be the case for timeouts. That is, if a timeout
 | 
	
		
			
				|  |  | +                # # longer than the eviction period is specified, we do not want
 | 
	
		
			
				|  |  | +                # # to cancel the RPC prematurely.
 | 
	
		
			
				|  |  | +                # if channel is next(iter(self._mapping.values()))[0]:
 | 
	
		
			
				|  |  | +                #     self._condition.notify()
 | 
	
		
			
				|  |  | +                # Move to the end of the map.
 | 
	
		
			
				|  |  | +                self._mapping.pop(key)
 | 
	
		
			
				|  |  | +                self._mapping[key] = (channel, datetime.datetime.now() + _EVICTION_PERIOD)
 | 
	
		
			
				|  |  | +                return channel
 | 
	
		
			
				|  |  | +            else:
 | 
	
		
			
				|  |  | +                channel = _create_channel(target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  | +                self._mapping[key] = (channel, datetime.datetime.now() + _EVICTION_PERIOD)
 | 
	
		
			
				|  |  | +                if len(self._mapping) == 1:
 | 
	
		
			
				|  |  | +                    self._condition.notify()
 | 
	
		
			
				|  |  | +                return channel
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    def _test_only_channel_count(self) -> int:
 | 
	
		
			
				|  |  | +        with self._lock:
 | 
	
		
			
				|  |  | +            return len(self._mapping)
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  def unary_unary(request: Any,
 | 
	
	
		
			
				|  | @@ -58,17 +134,21 @@ def unary_unary(request: Any,
 | 
	
		
			
				|  |  |                  call_credentials: Optional[grpc.CallCredentials] = None,
 | 
	
		
			
				|  |  |                  compression: Optional[grpc.Compression] = None,
 | 
	
		
			
				|  |  |                  wait_for_ready: Optional[bool] = None,
 | 
	
		
			
				|  |  | +                timeout: Optional[float] = None,
 | 
	
		
			
				|  |  |                  metadata: Optional[Sequence[Tuple[Text, Union[Text, bytes]]]] = None) -> Any:
 | 
	
		
			
				|  |  |      """Invokes a unary RPC without an explicitly specified channel.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    This is backed by an LRU cache of channels evicted by a background thread
 | 
	
		
			
				|  |  | +    This is backed by a cache of channels evicted by a background thread
 | 
	
		
			
				|  |  |      on a periodic basis.
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      TODO: Document the parameters and return value.
 | 
	
		
			
				|  |  |      """
 | 
	
		
			
				|  |  | -    channel = _get_cached_channel(target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    # TODO: Warn if the timeout is greater than the channel eviction time.
 | 
	
		
			
				|  |  | +    channel = ChannelCache.get().get_channel(target, options, channel_credentials, compression)
 | 
	
		
			
				|  |  |      multicallable = channel.unary_unary(method, request_serializer, request_deserializer)
 | 
	
		
			
				|  |  |      return multicallable(request,
 | 
	
		
			
				|  |  |                           metadata=metadata,
 | 
	
		
			
				|  |  |                           wait_for_ready=wait_for_ready,
 | 
	
		
			
				|  |  | -                         credentials=call_credentials)
 | 
	
		
			
				|  |  | +                         credentials=call_credentials,
 | 
	
		
			
				|  |  | +                         timeout=timeout)
 |