feat: add caching to GapicCallable (#527)
* feat: optimize _GapicCallable
* cleaned up metadata lines
* chore: avoid type checks in error wrapper
* Revert "chore: avoid type checks in error wrapper"
This reverts commit c97a6365028f3f04d20f26aa1cc0e3131164f53e.
* add default wrapped function
* fixed decorator order
* fixed spacing
* fixed comment typo
* fixed spacing
* fixed spacing
* removed unneeded helpers
* use caching
* improved metadata parsing
* improved docstring
* fixed logic
* added benchmark test
* update threshold
* run benchmark in loop for testing
* use verbose logs
* Revert testing
* used smaller value
* changed threshold
* removed link in comment
diff --git a/google/api_core/gapic_v1/method.py b/google/api_core/gapic_v1/method.py
index 0f14ea9..206549e 100644
--- a/google/api_core/gapic_v1/method.py
+++ b/google/api_core/gapic_v1/method.py
@@ -42,24 +42,6 @@
so the default should be used."""
-def _is_not_none_or_false(value):
- return value is not None and value is not False
-
-
-def _apply_decorators(func, decorators):
- """Apply a list of decorators to a given function.
-
- ``decorators`` may contain items that are ``None`` or ``False`` which will
- be ignored.
- """
- filtered_decorators = filter(_is_not_none_or_false, reversed(decorators))
-
- for decorator in filtered_decorators:
- func = decorator(func)
-
- return func
-
-
class _GapicCallable(object):
"""Callable that applies retry, timeout, and metadata logic.
@@ -91,6 +73,8 @@
):
self._target = target
self._retry = retry
+ if isinstance(timeout, (int, float)):
+ timeout = TimeToDeadlineTimeout(timeout=timeout)
self._timeout = timeout
self._compression = compression
self._metadata = metadata
@@ -100,35 +84,42 @@
):
"""Invoke the low-level RPC with retry, timeout, compression, and metadata."""
- if retry is DEFAULT:
- retry = self._retry
-
- if timeout is DEFAULT:
- timeout = self._timeout
-
if compression is DEFAULT:
compression = self._compression
-
- if isinstance(timeout, (int, float)):
- timeout = TimeToDeadlineTimeout(timeout=timeout)
-
- # Apply all applicable decorators.
- wrapped_func = _apply_decorators(self._target, [retry, timeout])
+ if compression is not None:
+ kwargs["compression"] = compression
# Add the user agent metadata to the call.
if self._metadata is not None:
- metadata = kwargs.get("metadata", [])
- # Due to the nature of invocation, None should be treated the same
- # as not specified.
- if metadata is None:
- metadata = []
- metadata = list(metadata)
- metadata.extend(self._metadata)
- kwargs["metadata"] = metadata
- if self._compression is not None:
- kwargs["compression"] = compression
+ try:
+ # attempt to concatenate default metadata with user-provided metadata
+ kwargs["metadata"] = (*kwargs["metadata"], *self._metadata)
+ except (KeyError, TypeError):
+ # if metadata is not provided, use just the default metadata
+ kwargs["metadata"] = self._metadata
- return wrapped_func(*args, **kwargs)
+ call = self._build_wrapped_call(timeout, retry)
+ return call(*args, **kwargs)
+
+ @functools.lru_cache(maxsize=4)
+ def _build_wrapped_call(self, timeout, retry):
+ """
+ Build a wrapped callable that applies retry, timeout, and metadata logic.
+ """
+ wrapped_func = self._target
+ if timeout is DEFAULT:
+ timeout = self._timeout
+ elif isinstance(timeout, (int, float)):
+ timeout = TimeToDeadlineTimeout(timeout=timeout)
+ if timeout is not None:
+ wrapped_func = timeout(wrapped_func)
+
+ if retry is DEFAULT:
+ retry = self._retry
+ if retry is not None:
+ wrapped_func = retry(wrapped_func)
+
+ return wrapped_func
def wrap_method(
diff --git a/tests/unit/gapic/test_method.py b/tests/unit/gapic/test_method.py
index d966f47..370da50 100644
--- a/tests/unit/gapic/test_method.py
+++ b/tests/unit/gapic/test_method.py
@@ -222,3 +222,24 @@
with pytest.raises(ValueError) as exc_info:
google.api_core.gapic_v1.method.wrap_method(method, with_call=True)
assert "with_call=True is only supported for unary calls" in str(exc_info.value)
+
+
+def test_benchmark_gapic_call():
+ """
+ Ensure the __call__ method performance does not regress from expectations
+
+ __call__ builds a new wrapped function using passed-in timeout and retry, but
+ subsequent calls are cached
+
+ Note: The threshold has been tuned for the CI workers. Test may flake on
+ slower hardware
+ """
+ from google.api_core.gapic_v1.method import _GapicCallable
+ from google.api_core.retry import Retry
+ from timeit import timeit
+
+ gapic_callable = _GapicCallable(
+ lambda *a, **k: 1, retry=Retry(), timeout=1010, compression=False
+ )
+ avg_time = timeit(lambda: gapic_callable(), number=10_000)
+ assert avg_time < 0.4