-
-
Notifications
You must be signed in to change notification settings - Fork 530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support Schema Extensions in subscriptions #2784
Support Schema Extensions in subscriptions #2784
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2784 +/- ##
==========================================
- Coverage 96.49% 94.45% -2.05%
==========================================
Files 510 505 -5
Lines 32808 31866 -942
Branches 5443 3659 -1784
==========================================
- Hits 31658 30098 -1560
- Misses 917 1477 +560
- Partials 233 291 +58 |
a4b4cc5
to
87fbff0
Compare
Current unit test flakiness is addressed by #2785 |
4923aa6
to
5a81ff3
Compare
Now, this is an internally breaking change in that the results from We could change the interface but it would impact the possibilities of what kind of context it is possible to maintain during the evaluation of a subscription. |
I think breaking it is fine, as long as we write it down :) |
Right, so please advise, if you like the approach I'm suggesting in this PR, then should I add the breakage to the RELEASE.md, similar to what was done with the recent |
we have a folder for breaking changes now: https://github.com/strawberry-graphql/strawberry/tree/main/docs/breaking-changes I'll update the version number before merging 😊 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey, I have worked on this PR long time ago though it got outdated... added some notes, if you want I can help.
async with extensions_runner.operation(): | ||
# Note: In graphql-core the schema would be validated here but in | ||
# Strawberry we are validating it at initialisation time instead | ||
assert execution_context.query is not None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should raise here MissingQueryError
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should it? I removed that code because it didn't get hit by coverage testing. It is my understanding that that can only happen if a query parameter is missing from a "query string", and we don't have these for subscriptions. Under what conditions could that possibly happen?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm I'm not sure I think @jthorniley added this.
during parsing or validation. | ||
Because we need to maintain execution context, we cannot return an | ||
async generator, we must _be_ an async generator. So we yield a | ||
(bool, ExecutionResult) tuple, where the bool indicates whether the result is an |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This tuple hack is not very pythonic IMO other than the fact that it is a breaking change... You might wanna check what I did at https://github.com/strawberry-graphql/strawberry/pull/2810/files#diff-88aa6fd17e4c6feac6e7152ebd3f2b8f972544c444a071b550e4d23061b97a3fR215 where if there is an error I return ExecutionResultError
which is basically the same as normal ExecutionResult
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is a good idea. Yes, tuples are problematic and thought this might be a sticking point. I'll create a special exception class instead, much nicer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, either an exception, or a special result class.. I think the exception might be cleaner, since one expects a subscription and the failure to get one is an exception of sorts. I'll see which one is nicer.
# Strawberry we are validating it at initialisation time instead | ||
assert execution_context.query is not None | ||
|
||
async with extensions_runner.parsing(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is duplicated from async execution, probably can be reused... see https://github.com/nrbnlulu/strawberry/blob/support_extensions_on_subscriptions/strawberry/schema/execute.py#L192
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll see if I can do that after I change the tuple semantics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did refactor the common validation tests
strawberry/schema/execute.py
Outdated
yield False, ExecutionResult(data=None, errors=execution_context.errors) | ||
return # pragma: no cover | ||
|
||
async def process_result(result: GraphQLExecutionResult): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is that defined here, on every execution?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Its a closure. Avoids writing a compicated external function and passing arguments, as well as scoping functionality.
Closures aren't actually defined when run, merely instantiated with bindings. I guess it is down to style, I very much favor closures for their conciseness and locality. Happy to change it if you like.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's a weak point for performance (though I agree it is much more readable), I would be happy if we would merge #2810 here or vice-versa. There I added a base class for async execution and it is done for subscription and async execution via https://github.com/nrbnlulu/strawberry/blob/support_extensions_on_subscriptions/strawberry/schema/execute.py#LL225C15-L225C39
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't aware of #2810, I'll look at that.
I just want to add, as a general comment, that I think you will find that performance is not affected.
First of all, instantiate a closure a drop in the ocean of all the stuff that goes on to actually start executing the subscription.
Secondly, all of the execution time of a subscription actually happens parsing and processing the multiple results. Setting up of an subscription is not performance critical.
Finally, you suggested using contextlib.suppress
, and that involves not only instantiating a class on every query, but also invoking two methods on it. There is quite a bit of machinery involved in a a contextlib.contextmanager
.
I understand when people are concerned about performance, but one always needs to look at that in context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand when people are concerned about performance, but one always needs to look at that in context.
Thanks. ig it is just something that bugs me... unless it is in a decorator I won't do it...
Sorry if i'm being too strict.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, its moved out of the function in the latest commit.
return | ||
|
||
aiterator = result.__aiter__() | ||
try: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not contextlib.suppress
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suppress what exactly? AttributeError? Because that exception might come from anywhere. This is surgically testing for the existence of the aclose method.
The need for this will go away with release 3.3.0 in graphql-core, where the subscribe() will return an async-generator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand.
Because that exception might come from anywhere
What's wrong with
with contextlib.supress(BaseException):
async for result in aiterator:
yield True, await process_result(result)
if hasattr(aiterator, "aclose"):
await aiterator.aclose()
AFAIK this is the same as what you are doing...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, you don't want to suppress BaseException
. You never want to do that. CancelledError
is one reason. And we would be suppressing any kind of Exception
happening during iteration, including errors we want to pass to the callers, internal errors, whatnot.
No, try-finally exists precisely to do this kind of thing. In fact, that is how contextlib.aclosing()
is implemented and it would be appropriate here, except that a) it requires python 3.8 and b), it assumes that aclose()
is present.
with graphql-core 3.3, there will be an aclose()
method present, and so aclosing()
can be used. We can implement it manually if using 3.7
78191f1
to
cce672d
Compare
ruff is such a moving target. v 270 gives me no errors. |
await asyncio.sleep(kwargs["sleep"]) | ||
return not self.fail | ||
|
||
|
||
class MyExtension(SchemaExtension): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this use
class MyExtension(ExampleExtension): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, well, those are defined inside test fixtures. It would be preferable to do it the other way round, since we have these extensions in the schema.
We would have to rewrite all of the http/websocket test bits to use schema extensions as fixtures.
We need the extension defined in the tests/views/schema.py, because that is the schema which is used for all the websocket unit tests, and we want to test the websockets/subscriptions against all the different integrations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be preferable to do it the other way round
sure.
By the way, I offer a different kind of resolving the "either return an ExecutionResult or an Iterator" thing, to more import asyncio
import contextlib
from typing import AsyncGenerator, Optional, Union, cast
incontext = False
@contextlib.asynccontextmanager
async def mycontext() -> AsyncGenerator[None, None]:
global incontext
incontext = True
try:
yield
finally:
incontext = False
async def _subscribe(dofail: bool) -> AsyncGenerator[Optional[str], None]:
async with mycontext():
# yield an initial value, which will be the single result value,
# or None if we're going to yield more values
if dofail:
yield "failure"
return # typically not reached
else:
yield None
# yield more values
yield "success"
yield "success2"
async def subscribe(dofail: bool) -> Union[str, AsyncGenerator[str, None]]:
# check the first value, and if it's not None, return it,
# aborting the generator
gen = _subscribe(dofail)
first = await gen.__anext__()
if first is not None:
await gen.aclose()
return first
else:
return cast(AsyncGenerator[str, None], gen)
async def main() -> None:
result = []
for dofail in [True, False]:
assert not incontext
v = await subscribe(dofail)
if isinstance(v, str):
result.append(v)
assert not incontext
else:
assert incontext
async for val in v:
result.append(val)
assert incontext
assert not incontext
assert result == ["failure", "success", "success2"]
if __name__ == "__main__":
asyncio.run(main()) Using this pattern, we could avoid the This essentially changes the interface back to what it was, which is probably preferable, it will reduce the size of the patch. The drawback is that now Both work. Which one is peferred by the maintainers? I'm happy to convert. |
🛑 Important, before merging this, please consider #2825, which is a less intrusive change. |
let me see if I can bring this up to date |
…ing a subscription
f7a3873
to
699a910
Compare
Still some minor fixes needed. |
There have recently been made some changes to extension error handling. |
@kristjanvalur thank you so much for keeping this PR up to date, @nrbnlulu made a new PR recently and we merged that, hope that's ok! |
Np, it is fine, great that you got it working. Now, I wonder if my other PRs need updating .... |
@kristjanvalur probably, there's been a lot of refactoring recently, I'll check with @DoctorJohn soon :) |
Subscriptions are now executed like queries and mutations, and extension hooks are called.
Result extension hooks are called for each result yielded by the subscription
NOTE: #2825 is an updated version of this, with a less intrusive change to the
schema
API.Description
the
Schema.subscribe()
is turned into anAsyncGenerator
yieldingExecutionResult
objects.In case the validation of the requests results in a single result instead of a subscription, e.g. when the inner
graphql-core.subscribe()
does not return an iterator, a special exception is thrown,SubscribeSingleResult
which'value
attribute contains the single
ExecutionResult
.This pattern, always being a
AsyncGenerator
which aborts under certain circumstances, makes for a cleaner interfaceand allows for much simpler use of context managers. Following the pattern of
graphql-core
which either returnsan iterator or an object, makes it unnecessarily tricky to implement with the Extensions context managers.
The
payload
part of the returned messages in both protocols is augmented with anextensions
member if added by the corresponding hooks.Tests are added to tests for result extensions.
A side effect of this change is that initial validation now happens on a worker task. This feature was a part of a separate PR in progress, so corresponding tests are also added to verify that functionality.
Types of Changes
Issues Fixed or Closed by This PR
Checklist