Skip to content

Conversation

andrewgodwin
Copy link
Member

@andrewgodwin andrewgodwin commented Aug 10, 2019

ticket-31224

This implements the ability to have asynchronous views in Django, by simply declaring a view as async def.

Under the hood, it rewrites the BaseHandler to be natively async and makes the WSGI request path run an async loop in-place for every request in order to service this. The ASGI request path down to a view is instead intended to be natively asynchronous all the way down.

This is still a work in progress, as it currently has several potential issues:

  • There are a small number of failing tests
  • Synchronous middleware works, but its presence means a thread is used every request, defeating the point of async views.
  • Class-based views do not have a clear path to async compatibility
  • There is not yet any documentation about how to use it

I'm putting it up as a draft PR so people have a place to easily see it and review the code so far.

@auvipy
Copy link
Contributor

auvipy commented Sep 24, 2019

is it too late to consider trio? for class-based async views, can we get inspiration from django-vanilla-views? what about making new async views instead of current class based views?

@andrewgodwin
Copy link
Member Author

@auvipy Unfortunately, while I like trio, it has a much smaller library ecosystem and so pragmatically we can't use it. My hope is eventually to make it possible to use in some situations.

Some more class-based views discussion is over on the forum (https://forum.djangoproject.com/c/internals/async) - while new async views is cleanest, it does mean having two versions of everything to maintain, which isn't great.

@andrewgodwin andrewgodwin force-pushed the async_views branch 2 times, most recently from 9eb4440 to 630f691 Compare September 26, 2019 17:52
Copy link

@aeros aeros left a comment

Choose a reason for hiding this comment

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

Greetings, @andrewgodwin.

More recently, I've become significantly involved with the development of asyncio. I'm by no means as experienced as Yury or Andrew, but I am quite familiar with the internals of asyncio and the low-level API.

I'm not familiar with the internals of Django and did not get a chance to look this over extensively, but I do have some feedback regarding the usage of asycnio.iscoroutine(). Specifically, asyncio.iscoroutine() supports the legacy generator coroutines, which are currently deprecated and scheduled for removal in Python 3.10.

As a result, I would recommend using inspect.iscoroutine() instead. With inspect.iscoroutine(), it only returns True if the coroutine was declared with the async def syntax. With asyncio.iscoroutine(), it will return True for the legacy generator coroutines declared with the @asyncio.coroutine decorator in addition to the async def coroutines.

This may not be a concern if you intend to support the legacy generator coroutines. I just wanted to provide some insight in case you weren't aware of the difference and don't intend to provide legacy support for the older coroutines. It's definitely not a particularly easy to notice difference if you were used to using asyncio.iscoroutine() in the past. At the moment, I'm not certain if there are plans to change asyncio.iscoroutine() to remove support for the legacy coroutines or deprecate the function entirely, I'll have to ask the others.

References

asyncio.iscoroutine():

Docs: https://docs.python.org/3/library/asyncio-task.html#asyncio.iscoroutine

Source code: https://github.com/python/cpython/blob/f900064ac4b35226caad7502abc8a7e64f1c0e9d/Lib/asyncio/coroutines.py#L164

inspect.iscoroutine():

Docs: https://docs.python.org/3/library/inspect.html#inspect.iscoroutine

Source code: https://github.com/python/cpython/blob/f900064ac4b35226caad7502abc8a7e64f1c0e9d/Lib/inspect.py#L189

@andrewgodwin
Copy link
Member Author

@aeros We use the iscoroutinefunction function from asyncio as it allows us to flag things that are effectively coroutine functions (i.e. callables that return a coroutine) that are not declared as async def. Specifically, this is used in our SyncToAsync wrapper that turns sync-callables into async-callables.

By my reading, the inspect version does not offer this, whereas the asyncio version lets you annotate functions with asyncio.coroutines._is_coroutine to mark them as such. Given this, we can't use the inspect version, as it's too narrow - it only returns functions that were declared async def, rather than actually telling you if something is a coroutine function.

@aeros
Copy link

aeros commented Oct 12, 2019

By my reading, the inspect version does not offer this, whereas the asyncio version lets you annotate functions with asyncio.coroutines._is_coroutine to mark them as such. Given this, we can't use the inspect version, as it's too narrow - it only returns functions that were declared async def, rather than actually telling you if something is a coroutine function.

@andrewgodwin That's correct. I mainly wanted to make sure that it was intentional and it's clear that doing so will indirectly support the older generator coroutines that are being deprecated in Python 3.10 (unless asyncio.iscoroutine() and asyncio.iscoroutinefunction() are changed). I wasn't aware of Django's SyncToAsync wrapper, but I can see why inspect.iscoroutine() would not be compatible with it.

@andrewgodwin andrewgodwin force-pushed the async_views branch 3 times, most recently from 1df9d06 to a22d053 Compare January 28, 2020 17:41
@andrewgodwin andrewgodwin marked this pull request as ready for review January 28, 2020 21:50
@andrewgodwin
Copy link
Member Author

andrewgodwin commented Jan 28, 2020

Given that this branch now passes all tests and shows no drop in sync performance in my testing, I consider it ready for review. I've also added relevant docs.

(I did not squash the commits down as I want to either do that with github's squash-and-merge, or wait until all requested changes are through and we're ready to go)

Copy link
Member

@jacobian jacobian left a comment

Choose a reason for hiding this comment

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

@andrewgodwin, this is lovely work. Clear, clean, and really easy to follow (surprisingly so - because the base handler has always been a bit complex; the refactoring you needed to do actually makes things easier to follow, for me at least). Thank you! I'm super-excited to give this a serious try.

@felixxm felixxm changed the title Implement async views Fixed #31224 -- Implemented asynchronous views. Feb 3, 2020
Copy link
Member

@carltongibson carltongibson left a comment

Choose a reason for hiding this comment

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

Hi @andrewgodwin.

Thanks for this. Really good.

...until we're a release or two in and there's more community knowledge about async views and how to write them

I like the docs how we are, for the stage we're at. I'll look again but, I think they're pitched about right.
(Very readable as ever 👍)

My main concern at this point is the test coverage. This is the heart of the framework and, there are a (small) number of lines in the changes here that aren't hit by tests. They're ≈all around sync/async, in say load_middleware(), check_none_response(), resolve_request(), etc. I think we need to ensure each branch is at least exercised.

Running coverage (where I always forget --parallel=, gaps (here) are in handlers/base.py, handlers/exception.py, handlers/wsgi.py (but see below), and utils/decorators.py.

It doesn't look like a million cases are needed, but, for example, there's no middleware testing the ..must have at least one of... error. And so on.

@andrewgodwin
Copy link
Member Author

Good point about the testing - I always tend to forget that late in a project (especially as I was mostly performance-testing this with a separate suite). I'll try to get some extra tests in today or over the weekend and get that coverage up.

@andrewgodwin
Copy link
Member Author

@carltongibson I've added more handler tests as well as the necessary AsyncClient we needed to support them, and then some user docs about using all this for your own async tests.

@carltongibson
Copy link
Member

Super @andrewgodwin! I saw the commit come in and had to laugh. Great work. Will look again tomorrow.

Copy link
Member

@carltongibson carltongibson left a comment

Choose a reason for hiding this comment

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

Hi @andrewgodwin. Getting there with this... 😅

There's a weirdness with using @async_to_sync on individual test methods.

If I run the test case class, no problem:

(django) django/tests (pr/11650)$ ./runtests.py handlers.tests.AsyncHandlerRequestTests
Testing against Django installed in 'django/django' with up to 4 processes
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.113s

OK

But running just a single test fails:

(django) django/tests (pr/11650)$ ./runtests.py handlers.tests.AsyncHandlerRequestTests.test_unawaited_response 
Testing against Django installed in 'django/django' with up to 4 processes
Traceback (most recent call last):

    ...[snip]...

  File "/Users/carlton/ve/django/lib/python3.7/site-packages/asgiref/sync.py", line 156, in main_wrap
    result = await self.awaitable(*args, **kwargs)
  File "django/tests/handlers/tests.py", line 284, in test_unawaited_response
    with self.assertRaisesMessage(ValueError, msg):
AttributeError: 'NoneType' object has no attribute 'assertRaisesMessage'

Looking at this in the debugger, moving up to the async_to_sync wrapper, we have:

(Pdb) u
> /Users/carlton/ve/django/lib/python3.7/site-packages/asgiref/sync.py(156)main_wrap()
-> result = await self.awaitable(*args, **kwargs)
(Pdb) l
151  	                try:
152  	                    raise exc_info[1]
153  	                except:
154  	                    result = await self.awaitable(*args, **kwargs)
155  	            else:
156  ->	                result = await self.awaitable(*args, **kwargs)
157  	        except Exception as e:
158  	            call_result.set_exception(e)
159  	        else:
160  	            call_result.set_result(result)
161  	        finally:
(Pdb) self
<asgiref.sync.AsyncToSync object at 0x1105a9e80>
(Pdb) self.awaitable
<function AsyncHandlerRequestTests.test_sync_view at 0x1105c0ae8>

If I wrap with method_decorator, e.g.

    @method_decorator(async_to_sync)
    async def test_sync_view(self):
        ...

The tests are correct, the test's self is correct, and looking in the debugger
we have a bound method:

(django) django/tests (pr/11650)$ ./runtests.py handlers.tests.AsyncHandlerRequestTests.test_sync_view --parallel=1
Testing against Django installed in 'django/django'
System check identified no issues (0 silenced).
> django/tests/handlers/tests.py(256)test_sync_view()
-> response = await self.async_client.get('/regular/')
(Pdb) self
<handlers.tests.AsyncHandlerRequestTests testMethod=test_sync_view>
(Pdb) c
.
----------------------------------------------------------------------
Ran 1 test in 7.930s

OK
(django) ~/Documents/Django-Stack/django/tests (pr/11650)$ ./runtests.py handlers.tests.AsyncHandlerRequestTests.test_sync_view --parallel=1
...
(Pdb) u
> /Users/carlton/ve/django/lib/python3.7/site-packages/asgiref/sync.py(156)main_wrap()
-> result = await self.awaitable(*args, **kwargs)
(Pdb) self.awaitable
functools.partial(<bound method AsyncHandlerRequestTests.test_sync_view of <handlers.tests.AsyncHandlerRequestTests testMethod=test_sync_view>>)
(Pdb) c
.
----------------------------------------------------------------------
Ran 1 test in 229.461s

OK

Not (at this moment) 100% sure why it works running the whole test case because
the awaitable is still the function, rather than the bound method...

(django) django/tests (pr/11650)$ ./runtests.py handlers.tests.AsyncHandlerRequestTests --parallel=1
Testing against Django installed in 'django/django'
System check identified no issues (0 silenced).
...> django/tests/handlers/tests.py(257)test_sync_view()
-> response = await self.async_client.get('/regular/')
(Pdb) u
> /Users/carlton/ve/django/lib/python3.7/site-packages/asgiref/sync.py(156)main_wrap()
-> result = await self.awaitable(*args, **kwargs)
(Pdb) self.awaitable
<function AsyncHandlerRequestTests.test_sync_view at 0x10b660a60>
(Pdb) c
..
----------------------------------------------------------------------
Ran 5 tests in 21.410s

OK

...so I need to dig a little more.

However...

What do you think about this case? (In the testing docs, should we be mentioning
that you probably should be using method_decorator?)

@andrewgodwin
Copy link
Member Author

@carltongibson Ah, interesting! I only ever run the test suites rather than the individual methods, so I never ran into this.

Let me go dive in and see if I can figure out a way to have Django do this automatically; I had considered making the test runner just auto-apply async_to_sync, so maybe this is the time to do this.

@carltongibson
Copy link
Member

carltongibson commented Feb 12, 2020

Hey @andrewgodwin Super. Thanks.

Whilst you're looking... My other (currently proto-) question is, Can we reduce the number of async defs in test/client.py?

  • Does AsyncRequestFactory need async def at all?
    • If yes, can it just be request(), but that doesn't await anything so...?
  • Then we just have async def request() in AsyncClient, which does await.

Am still playing with it, but thought I'd mention it early since you're here. 🙂

Other than that, this is looking good I think. (Time to ask @felixxm to get out the fine-toothed comb, before another fine read of the docs...) Thanks for the super effort.

@andrewgodwin
Copy link
Member Author

@carltongibson I don't think I can stop AsyncRequestFactory.request being one - since its child AsyncClient.request is - but I might be able to remove it from AsyncRequestFactory.generic. Will have a go.

@carltongibson
Copy link
Member

I think that's allowed:

import asyncio


class Parent:
    def foo(self):
        return "Hello"


class Child(Parent):
    async def foo(self):
        return super().foo()


async def main():
    c = Child()
    return await c.foo()


if __name__ == '__main__':
    print(
        asyncio.run(main())
    )

Runs as

~/Desktop $ python async_subclass.py 
Hello

Nothing seemed to break when I was experimenting earlier.


I see you rebased. Failures are from 4e8d6a1

@felixxm
Copy link
Member

felixxm commented Mar 12, 2020

@andrewgodwin In the last commit aff07dc527f9ae6b91c68ed741258fbd15d74045 I tried to use AsyncRequestFactory in asgi.tests. Can you take a look?

@andrewgodwin
Copy link
Member Author

@carltongibson That fixup looks good to me - thanks for doing all the Sphinx linking that I often forget!

@felixxm That looks like a sensible change to me, nice way of reducing the scope duplication.

@felixxm
Copy link
Member

felixxm commented Mar 12, 2020

I moved documentation of async adapter functions to a separate PR #12562.

@felixxm
Copy link
Member

felixxm commented Mar 14, 2020

@andrewgodwin I think AsyncRequestFactory is missing in docs/topics/testing/advanced.txt 🤔 (django/test/client.py is the last file that I'm checking/polishing, so we're really close 🥇).

@felixxm
Copy link
Member

felixxm commented Mar 16, 2020

I push small changes:

  • made AsyncClient and AsyncRequestFactory importable from django.test,
  • added basic tests for HTTP methods of AsyncRequestFactory.

@andrewgodwin
Copy link
Member Author

I added a small section about AsyncRequestFactory in the docs just now, too.

@carltongibson carltongibson force-pushed the async_views branch 2 times, most recently from fe0ddbc to 97ead93 Compare March 17, 2020 10:03
@felixxm
Copy link
Member

felixxm commented Mar 17, 2020

@andrewgodwin Can you take a look at our recent changes?

@carltongibson
Copy link
Member

carltongibson commented Mar 18, 2020

I just pushed 40ca60c10f09edc0a24acf756c8daf471f8b82de, which adds an:

await communicator.wait() 

... at the end of the FileResponse test case asgi.tests.ASGITest.test_file_response

Previously we had a:

Exception ignored in: <_io.FileIO name='.../django/tests/asgi/urls.py' mode='rb' closefd=True>
Traceback (most recent call last):
  File ".../3.9.0a4/lib/python3.9/asyncio/events.py", line 81, in _run
    self._context.run(self._callback, *self._args)
ResourceWarning: unclosed file <_io.BufferedReader name='...django/tests/asgi/urls.py'>

This was caused by the sync_to_async(response.close) future getting a asyncio.exceptions.CancelledError when the communcator went out of scope at the end of the test case.

We just need to make sure the app has a chance to finish in these cases.
(Noting for future. 🙂)

@andrewgodwin
Copy link
Member Author

Yup, all these edits look good to me. Thanks to both of you!

@carltongibson
Copy link
Member

Cheers @andrewgodwin. Thanks for the confirmation.

I think we're there 🤞. @felixxm can press the button. 😀

💃⛵️🎇

This implements support for asynchronous views, asynchronous tests,
asynchronous middleware, and an asynchronous test client.
@felixxm felixxm changed the title Fixed #31224 -- Implemented asynchronous views. Fixed #31224 -- Added support for asynchronous views and middleware. Mar 18, 2020
@felixxm felixxm merged commit fc0fa72 into django:master Mar 18, 2020
@felixxm
Copy link
Member

felixxm commented Mar 18, 2020

Thanks y'all 🥇 🚀 🎊

@andrewgodwin
Copy link
Member Author

Thanks so much for all your hard work reviewing!

Now, let's take a look at that ORM... :)

@jezdez
Copy link
Contributor

jezdez commented Mar 19, 2020

@andrewgodwin 🤯!

@stlk
Copy link
Contributor

stlk commented Mar 21, 2020

Thank you for this!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.