Skip to content

[Aio] Implement server interceptor for unary unary call#22032

Merged
lidizheng merged 13 commits intogrpc:masterfrom
ZHmao:implement-server-interceptor-for-unary-unary-call
Mar 5, 2020
Merged

[Aio] Implement server interceptor for unary unary call#22032
lidizheng merged 13 commits intogrpc:masterfrom
ZHmao:implement-server-interceptor-for-unary-unary-call

Conversation

@ZHmao
Copy link
Contributor

@ZHmao ZHmao commented Feb 15, 2020

Support for interceptors for the unary-unary call at the server-side.

Fix #21914
Ref #20482


async def _find_method_handler(str method, tuple metadata, list generic_handlers,
tuple interceptors):
def query_handlers(handler_call_details):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we replaced this by a geneator expression + next() ?

method_handlers = (generic_handler.service(handler_call_details) for generic_handler in generic_handlers)
method_handler = next((mh for mh in method_handlers if mh is not None), None)

Copy link
Contributor Author

@ZHmao ZHmao Feb 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to iterate them in _run_interceptors[1] if we use generator at here.
IMO, current version will make _run_interceptors simpler.

[1] https://github.com/grpc/grpc/pull/22032/files/6fef56573e9a0347c33c5aea4bc57ab625b0c6ea#diff-b53c077f7911f53dc8ce7656e16d35cdR218


import inspect
import traceback
import functools

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this being used? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

@pfreixes pfreixes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work done!

First round of comments, Ive just focused on the tests

self.fail("Callback was not called")


class _LoggingServerInterceptor(aio.ServerInterceptor):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move all of the server test interceptor logic under a new file, like sever_interceptor_test.py, and maybe I would rename the former one to client_interceptor_test.py


with self.assertRaises(aio.AioRpcError):
server_target, _ = await start_test_server(
interceptors=(InvalidInterceptor(),))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a Channel with an invalid interceptor is not allowed to be instantiated [1 ], having the feeling that we would need to do the same with the server.

[1] https://github.com/grpc/grpc/blob/master/src/python/grpcio/grpc/experimental/aio/_channel.py#L317

# in the right order.
self.assertSequenceEqual(['log1:intercept_service',
'log2:intercept_service',], self._record)
self.assertIsInstance(response, messages_pb2.SimpleResponse)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can have another test - test_response_ok ? - for checking that the response is looking as we would be expecting by checking the status and the response content. With this specific test, we won't need to check at each test the response

_LoggingServerInterceptor('log1', self._record),
conditional_interceptor,
_LoggingServerInterceptor('log2', self._record),
)
Copy link
Collaborator

@pfreixes pfreixes Feb 18, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having all of the interceptors added into the setup which they would be used later for all of the tests makes the readability a bit harder for me, TBH I would prefer to have each test case with its own server with its own interceptors for testing the different functionalities.

The same as you already did for the test_invalid_interceptor test case.

Having the feeling that having this kind of sharing, the same happens with the _record class attribute, we have a lot of chances of creating down-side effects between tests.

return _GenericServerInterceptor(intercept_service)


class TestServerInterceptor(AioTestBase):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to have a test for checking observability of the code responses sent by the handler, in the same way as we have for the channel [1]

[1] https://github.com/grpc/grpc/blob/master/src/python/grpcio_tests/tests_aio/unit/interceptor_test.py#L93

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server interceptors are wrapped at find_method_handler , the interceptors won't get the status code of response.

@pfreixes pfreixes self-assigned this Feb 18, 2020
@pfreixes pfreixes added lang/Python release notes: yes Indicates if PR needs to be in release notes kind/enhancement labels Feb 18, 2020
Copy link
Contributor

@lidizheng lidizheng left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work!

return await _run_interceptor(iter(interceptors), query_handlers,
handler_call_details)
else:
return query_handlers(handler_call_details)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both _run_interceptor and _find_method_handler functions are on the data path, is it possible to simplify them? Or run the benchmark to check the impact on performance?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about doing something like:

async def  _find_method_handler(...):

    for generic_handler in generic_handlers:
        method_handler = generic_handler.service(handler_call_details)
        if method_handler is not None:
            break

    if interceptors:
        return await _run_interceptor(....., method_handler)
    else:
        return method_handler

With this change theoretically the execution path when there is no interceptors should not be hammered.

WDYT? /cc @lidizheng

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on whether we allow users to change the method.

If allowed, the method handler needs to be ran as last function in the chain, otherwise users' interception might not work.

If not allowed, this is a valid optimization.

return await self._fn(continuation, handler_call_details)


def _filter_server_interceptor(condition, interceptor):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To improve the maintainability of test cases, we might want to add type annotations, especially for callbacks.

'log3:intercept_service',
'log2:intercept_service',],
self._record)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add one more test case that the interceptor returns a different method handler?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't understand this one, can you give me more details?

Copy link
Contributor

@gnossen gnossen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM pending resolution of everyone else's comments.

return await self._fn(continuation, handler_call_details)


def _filter_server_interceptor(condition, interceptor):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool usage of interceptors. 👍

@mehrdada
Copy link
Contributor

mehrdada commented Feb 20, 2020

One of my regrets with the original design for sync interceptors is allowing server-interceptors to be flexible and change the path which massively limits the opportunity of optimization for handing over to handlers and doing so is non-idiomatic gRPC anyway. Please make sure you at least revisit whether this flexibility is worth the cost.

@lidizheng
Copy link
Contributor

@mehrdada By

... which massively limits the opportunity of optimization for handing over to handlers...

Are you referring to the potential optimization in finding methods? Otherwise, it could be done in Cython or C++? Can you explain more about this topic?

@pfreixes
Copy link
Collaborator

@ZHmao also consider that current PR has conflicts that are preventing us to run the whole CI, I would appreciate if you could resolve the conflicts.

Thanks!

@mehrdada
Copy link
Contributor

mehrdada commented Feb 21, 2020

@lidizheng C-Core server supports registering RPC paths directly upfront as opposed to everything be "the generic handler". Python API is clearly designed to be able to leverage it (see ServiceRpcHandler and GenericRpcHandler) but never did. If we know which handler is going to be invoked upfront, we can create the interceptor handling pipeline per path upfront at server start time and have C core or Cython via a proper efficient hashtable that can run outside GIL or whatever handle it efficiently before we hand it over to Python, basically letting slow application code to resolve which method handler is going to be invoked. With the generic server interceptor API, we give user flexibility to change it which is often overkill and we won't know the handler to dispatch to upfront.

@ZHmao
Copy link
Contributor Author

ZHmao commented Feb 21, 2020

@ZHmao also consider that current PR has conflicts that are preventing us to run the whole CI, I would appreciate if you could resolve the conflicts.

Thanks!

Sure, I will.

@pfreixes
Copy link
Collaborator

@mehrdada @lidizheng having the chance of adding interceptors for specific handlers is something that could be really useful, not only because of performance but because the user has the chance of adding a piece of logic that is related to a specific path.

On the other hand, generic handlers are gonna be needed still for doing generic stuff like observability, tracing, etc ...

IMO the interceptors discussion with regards the design is not yet closed, we have two outstanding g questions that would need to be answered before making this feature as no longer experimental:

  • The granularity of the interceptors. how does the current design work with the other arities? A design based on hooks would make everything easier? and more important less prone error?
  • Generic handlers vs route/path handlers. How we would implement both use cases? do the route/path handlers make sense on the client-side?

Right now we are on the way of doing an alpha release and this feature is marked as experimental, so I guess that we have still full freedom on breaking the API or extending it without having the friction of being in a beta/stable release.

@mehrdada
Copy link
Contributor

mehrdada commented Feb 22, 2020

@pfreixes I am fine with releasing alpha or whatever as long as it can be changed. To clarify, my concern is a bit orthogonal to interceptors being able to attach to specific paths (although I am not necessarily a fan, it is a valid feature to debate). The limitation that I like to see enforced is interceptor not being able to alter the path it is invoked on, i.e. return a different path than the one it's invoked on. It is valid for it to see the path. It might require more work and a more complex API, but it is also alright if it can be configured to get invoked on certain paths and not others as long as all this info is known upfront on server start, because you can still construct the handler pipelines once ahead of the time and dispatch accordingly, even if the pipelines vary per request path.

return None
# interceptor
if interceptors:
return await _run_interceptor(iter(interceptors), query_handlers,
Copy link
Collaborator

@pfreixes pfreixes Feb 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we return and not execute, the _run_interceptor coroutine? This would mean that the execution will eventually happen at the very last step and the interceptor for example in the case of a unary call will be here [1]

Indeed we could name this method like _wrap_call_interceptor or something like that.

Without doing this, future interceptors that would take care of observability for measuring the time that handler took won't work since they won't be able to measure the real execution of the handler.

Also moving this to the right place, will eventually will help us for extending the interceptors for having visibility of the servicer context [2] at the interceptor level.

The only friction point of doing this that I can see, if Im not missing something is the access to the metadata. So the interceptor won't be able to have direct access to the metadata and mutate them, but could have access to the service context that will make the metadata available for the interceptor.

WDYT @lidizheng should we aim for moving the execution of the interceptor to the very last step?

Also, I'm wondering if since we are not most likely commit to having this feature for the alpha release we could start thinking on how a nex iteration of the servers interceptors could be done for knowing the response code of the method handler. Would this change help us to do so?

[1] https://github.com/grpc/grpc/blob/master/src/python/grpcio/grpc/_cython/_cygrpc/aio/server.pyx.pxi#L242
[2] https://github.com/grpc/grpc/blob/master/src/python/grpcio/grpc/_cython/_cygrpc/aio/server.pyx.pxi#L244

Copy link
Contributor

@lidizheng lidizheng Feb 26, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the server interceptors should be more powerful. In fact, it would be great, if it has similar functionality as the client-side.

Back to your proposal, it might introduce a regression if users are selecting method handlers according to the invocation metadata (client-sent initial metadata). This feature is supported in the existing stack, even without interceptors (although I wonder if any user uses it).

If we decided to optimize the design of generic handlers (basically let the Core route the requests), then users cannot use this feature anyway. In this case, yes, I think this proposal is valid.


should we aim for moving the execution of the interceptor to the very last step? ... could be done for knowing the response code of the method handler.

If we want a proper full-featured server interceptor, we can make interceptors wrapping entire method handler (similar to client-side). If we only executed it till the last step, users can only either inject logic before or after the RPC (or I might be wrong).

If we only want an interceptor design better than the existing stack, I believe the move does improve server interceptors capability.

@pfreixes
Copy link
Collaborator

@lidizheng and @ZHmao Im happy on having an iterative process for implementing the server interceptors, so having first implemented a version that works as the legacy stack is IMO already giving value.

So I would not have any inconvenience in approving this PR and later on have other PR for making the server interceptors more powerful. Indeed I would say that it will be the best thing to do.

But there is something that I do not understand, looking at this interceptor [1] that is the one used by one of our first adopters of gRPC that are using the legacy stack, seems that the server version allows you to at least:

  • Catch exceptions returned by the handler
  • Get the response returned by the handler
  • Measure the time spent within the handler

Looking at the PR seems that neither of the previous capabilities are ready to be used. am I wrong? what Im missing?

[1] https://github.com/opentracing-contrib/python-grpc/blob/325edbb83f08110e7d2ea1d1c453aaf61666b6e7/grpc_opentracing/_server.py#L139

@ZHmao
Copy link
Contributor Author

ZHmao commented Feb 27, 2020

Hi @pfreixes , they used a new class _InterceptorServer to wrap the original server [1], and changed the behavior of add_generic_rpc_handlers, they added interceptor for every rpc handler.

[1] https://github.com/opentracing-contrib/python-grpc/blob/325edbb83f08110e7d2ea1d1c453aaf61666b6e7/grpc_opentracing/grpcext/_interceptor.py#L342

@pfreixes
Copy link
Collaborator

@ZHmao wow, I missed that! So they did what we would like to have.... great example on how the users follow their own paths for circumventing the current limitations.

So in that case, if we are already meeting the same requirements that are met by the legacy stack take me 👍 but I would like to see a plan for designing a server interceptor that supersedes the current one and is paired to what lightstep [1] has done.

PTAL to the sanity checks, most. of them are failing.

[1] https://github.com/opentracing-contrib/python-grpc/blob/325edbb83f08110e7d2ea1d1c453aaf61666b6e7/grpc_opentracing/grpcext/_interceptor.py#L342

Copy link
Contributor

@lidizheng lidizheng left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work and great test coverage!

@@ -0,0 +1,144 @@
# Copyright 2019 The gRPC Authors.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Copyright 2019 The gRPC Authors.
# Copyright 2020 The gRPC Authors

"""
self._loop.create_task(self._server.shutdown(None))
if hasattr(self, '_server'):
self._loop.create_task(self._server.shutdown(None))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@ZHmao
Copy link
Contributor Author

ZHmao commented Feb 28, 2020

Thanks for all your reviews.

Copy link
Contributor

@lidizheng lidizheng left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment history got hidden by GitHub. Raise a comment that I forget to follow-up.

return await self._fn(continuation, handler_call_details)


def _filter_server_interceptor(condition: Callable,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this one in my last round of review. By adding type annotation, I mean what is the argument it accepts and returns. You can do this like:

def atoi(a: str) -> int:
    return int(a)

atoi: Callable[[str], int]

Also, if possible, please annotate all other arguments in this file, not only this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

@ZHmao
Copy link
Contributor Author

ZHmao commented Feb 29, 2020

I have added type annotation for server_interceptor_test.py, and changed the return type of continuation from grpc.RpcMethodHandler to Awaitable[grpc.RpcMethodHandler] [1].

But it failed when I ran python -m pytype .
The error message:

in _filter_server_interceptor: Function _GenericInterceptor.__init__ was called with the wrong arguments [wrong-arg-types]
  Expected: (self, fn: Callable[[Callable[[grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler]], grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler]])
  Actually passed: (self, fn: Callable[[Callable[[grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler]], grpc.HandlerCallDetails], grpc.RpcMethodHandler])

[1] https://github.com/grpc/grpc/pull/22032/files#diff-173e8bd5d2219ba109f86237680068a4R41-R42

cc @lidizheng

@lidizheng
Copy link
Contributor

@ZHmao I'm not sure I understand the question here. Did you mean the pytype has a deduction error on the types? In that case, you can post it as an issue to pytype. Otherwise, the error message seems clear that there might be a confusion in the implementation.

(As I said, complex callbacks are usually confusing and needs proper typing.)

@ZHmao
Copy link
Contributor Author

ZHmao commented Mar 3, 2020

I have written an example code and it shows the same error:
in go: Function bar was called with the wrong arguments [wrong-arg-types] Expected: (fun: Callable[[str], Awaitable[str]]) Actually passed: (fun: Callable[[str], str])

from typing import Callable, Awaitable


async def foo(a: str) -> str:
    return a


async def bar(fun: Callable[[str], Awaitable[str]]) -> str:
    return await fun('a')


async def go() -> None:
    await bar(foo)

I don't know what's wrong, do you have any advice?

@lidizheng
Copy link
Contributor

@ZHmao Thanks for the convincing code snippet, good find! It looks like pytype has a bug, that it unable to express async functions correctly. In the generated .pyi file, it recognized that foo is an async function that returns a coroutine. However, when you passes foo as an argument, pytype complains.

# Generated .pyi file
def bar(fun: Callable[[str], Awaitable[str]]) -> Coroutine[Any, Any, str]: ...
def foo(a: str) -> Coroutine[Any, Any, str]: ...
def go() -> Coroutine[Any, Any, None]: ...

If you have some free cycle and want to be a contributor of pytype, feel free to open an issue or post a PR directly to https://github.com/google/pytype. You can find their unit test here: https://github.com/google/pytype/blob/master/pytype/tests/py3/test_coroutine.py.

On the other hand, to move this PR forward, there are two (hacky) ways to solve the pytype complain.

  1. Use Union for the return value: Union[grpc.RpcMethodHandler, Awaitable[grpc.RpcMethodHandler]];
  2. Use Any for the return value.

@pfreixes Have you encountered this bug before?

@ZHmao
Copy link
Contributor Author

ZHmao commented Mar 4, 2020

I'll use Union to move this PR forward, and open an issue or post a PR on pytype later.

@ZHmao
Copy link
Contributor Author

ZHmao commented Mar 4, 2020

FYI, when I tried to fix this issue with Union, it failed again. Then I wrote another example more like ours.😅

from typing import Callable, Awaitable, Union


async def foo(a: str) -> str:
    return a


class Bar:

    def __init__(self, fun: Callable[[str], Union[Awaitable[str], str]]) -> None:
        self.fun = fun

    async def bar(self) -> str:
        return await self.fun('a')


async def go() -> None:
    Bar(foo)

Error message:

in bar: bad option in return type [bad-return-type]
           Expected: Awaitable
  Actually returned: str

But type Any is OK in this situation. So I'll use Any to move this PR forward.

cc @lidizheng @pfreixes

@lidizheng
Copy link
Contributor

@ZHmao Thanks for the update. This definitely looks like an issue for pytype.

@ZHmao
Copy link
Contributor Author

ZHmao commented Mar 5, 2020

Can we merge it? Or I need to make all checks pass before we can merge it?

@lidizheng
Copy link
Contributor

Known failures: #22208 #19834

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/enhancement lang/Python release notes: yes Indicates if PR needs to be in release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support for interceptors for the unary-unary call at the server-side

7 participants