Skip to content
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

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

kristjanvalur
Copy link
Contributor

@kristjanvalur kristjanvalur commented May 27, 2023

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 an AsyncGenerator yielding ExecutionResult 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 interface
and allows for much simpler use of context managers. Following the pattern of graphql-core which either returns
an 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 an extensions 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

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Issues Fixed or Closed by This PR

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

@codecov
Copy link

codecov bot commented May 27, 2023

Codecov Report

Attention: Patch coverage is 96.23188% with 13 lines in your changes are missing coverage. Please review.

Project coverage is 94.45%. Comparing base (17f05a7) to head (ef6688d).

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     

@botberry
Copy link
Member

botberry commented May 27, 2023

Thanks for adding the RELEASE.md file!

Here's a preview of the changelog:


Subscriptions now support Schema Extensions.

Here's the tweet text:

🆕 Release (next) is out! Thanks to @kristjanvalur for the PR 👏

Get it here 👉 https://strawberry.rocks/release/(next)

@kristjanvalur kristjanvalur force-pushed the kristjan/subscription-extensions branch 4 times, most recently from a4b4cc5 to 87fbff0 Compare May 31, 2023 07:51
@kristjanvalur
Copy link
Contributor Author

Current unit test flakiness is addressed by #2785

@kristjanvalur kristjanvalur force-pushed the kristjan/subscription-extensions branch 3 times, most recently from 4923aa6 to 5a81ff3 Compare May 31, 2023 22:15
@kristjanvalur
Copy link
Contributor Author

Now, this is an internally breaking change in that the results from schema.subscribe() have changed. but is this a documented interface?

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.

@patrick91
Copy link
Member

Now, this is an internally breaking change in that the results from schema.subscribe() have changed. but is this a documented interface?

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 :)

@kristjanvalur
Copy link
Contributor Author

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 channels PR?

@patrick91
Copy link
Member

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 channels PR?

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 😊

Copy link
Member

@nrbnlulu nrbnlulu left a 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
Copy link
Member

Choose a reason for hiding this comment

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

Should raise here MissingQueryError

Copy link
Contributor Author

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?

Copy link
Member

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
Copy link
Member

@nrbnlulu nrbnlulu Jun 5, 2023

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

Copy link
Contributor Author

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.

Copy link
Contributor Author

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():
Copy link
Member

Choose a reason for hiding this comment

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

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'll see if I can do that after I change the tuple semantics.

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 did refactor the common validation tests

yield False, ExecutionResult(data=None, errors=execution_context.errors)
return # pragma: no cover

async def process_result(result: GraphQLExecutionResult):
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

@nrbnlulu nrbnlulu Jun 5, 2023

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

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 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.

Copy link
Member

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.

Copy link
Contributor Author

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:
Copy link
Member

Choose a reason for hiding this comment

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

why not contextlib.suppress?

Copy link
Contributor Author

@kristjanvalur kristjanvalur Jun 5, 2023

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.

Copy link
Member

@nrbnlulu nrbnlulu Jun 5, 2023

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...

Copy link
Contributor Author

@kristjanvalur kristjanvalur Jun 5, 2023

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

@kristjanvalur kristjanvalur force-pushed the kristjan/subscription-extensions branch 4 times, most recently from 78191f1 to cce672d Compare June 5, 2023 16:08
@kristjanvalur
Copy link
Contributor Author

kristjanvalur commented Jun 5, 2023

ruff is such a moving target. v 270 gives me no errors.
You probably want to add ruff as dev-dependency and pin it to a version

await asyncio.sleep(kwargs["sleep"])
return not self.fail


class MyExtension(SchemaExtension):
Copy link
Member

@nrbnlulu nrbnlulu Jun 5, 2023

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):
?

Copy link
Contributor Author

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.

Copy link
Member

@nrbnlulu nrbnlulu Jun 6, 2023

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.

@kristjanvalur
Copy link
Contributor Author

kristjanvalur commented Jun 6, 2023

By the way, I offer a different kind of resolving the "either return an ExecutionResult or an Iterator" thing, to more
closely match what graphql-core does. Basically, it involves an outer helper around an inner generator.
The code looks like this:

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 SubscribeSingleResult exception, but we would then, again, have to check the
type of schema.subscribe() result to determine if it is a ExecutionResult or an iterator. The started generator is returned, maintaining the state of all context managers.

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
validation will happen before a Task is created, so I will also need to revert that unittest, but that's fine, I have a different PR to fix the validation and preparation of websocket operations anyway.

Both work. Which one is peferred by the maintainers? I'm happy to convert.

@kristjanvalur
Copy link
Contributor Author

kristjanvalur commented Jun 8, 2023

🛑 Important, before merging this, please consider #2825, which is a less intrusive change.

@patrick91
Copy link
Member

@kristjanvalur sorry for my lazyness, could you show me the two different APIs here? (or even another issue)

@kristjanvalur
Copy link
Contributor Author

kristjanvalur commented Jul 25, 2023

here we go. 2784 style:

result_source = self.schema.subscribe(...)
try:
    async for result_source:
        await self.send_result(operation, result, False)
except SubscribeSingleResult as single_result:
    await self.send_result(operation, single_result.value, True)
finally:
    await result_source.aclose()

2825 style (and the current style in code, inherited from graphql-core)

result_source = self.schema.subscribe(...)
if isinstance(result_source, ExecutionResult):
    await self.send_result(operation, result_source, True)
    return
try:
    async for result in result_source:
        await self.send_result(operation, result, False)
finally:
    await result_source.aclose()

Basically, switching on the result value type, or treating a single result (usually validation error) as an "exceptional" case.

The api must distinguish between the two cases (cannot return the error as a single result iterator) because the application will respond differently. An error during iteration is not fatal, an initial error is.

@kristjanvalur kristjanvalur force-pushed the kristjan/subscription-extensions branch from abfd60e to ad6ca49 Compare August 25, 2023 13:40
@XChikuX
Copy link
Contributor

XChikuX commented May 3, 2024

@patrick91 Can we merge this?

@kristjanvalur
Copy link
Contributor Author

let me see if I can bring this up to date

@kristjanvalur kristjanvalur force-pushed the kristjan/subscription-extensions branch from f7a3873 to 699a910 Compare May 3, 2024 13:14
@kristjanvalur
Copy link
Contributor Author

Still some minor fixes needed.
Please consider also if you think pr #2825 is a better api, see discussion above.

@kristjanvalur
Copy link
Contributor Author

There have recently been made some changes to extension error handling.
It is unclear to me how to translate that to this PR.
Note that errors during evaluation of the request need to be raised as SingleValueException with an ExecutionResult.
After that, ExecutionResults are not fatal.
But unexpected exceptions, such as should indeed be fatal during iteration.

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

Successfully merging this pull request may close these issues.

None yet

5 participants