Asynchronous ORM

Unfortunately, the pandemic meant that we didn’t get anything of note async-wise into Django 3.2, just some small improvements to the existing support.

2 Likes

Not a problem. Thanks for the update and for all the great work on Django. :slight_smile:

What you’re proposing doesn’t make sense to me. If the ORM does not yield during database calls then it will tie up the OS thread that is running the async caller. That will result in lower system performance than just running Django in sync mode.

Nope. Database calls go over the network. Hundreds if not thousands of times slower than regular CPU-bound calculations.

I think this is definitely an interesting idea. @andrewgodwin any thoughts?

An implementation in Django itself I think would look like:

from asyncio import get_running_loop  # Python 3.7+

class Model(...):  # django/db/models/base.py
    def save(self, *args, **kwargs):
        try:
            get_running_loop()
        except RuntimeError:  # no loop exists
            return self._save_sync(*args, **kwargs)
        else:  # loop exists
            return self._save_async(*args, **kwargs)
    
    def _save_sync(self, ...):
        ...  # the original sync implementation
    
    async def _save_async(self, ...):
        ...  # the new async implementation

And then you could have user code that looks like:

def my_endpoint_sync(request):
    first_post = Post.objects.first()
    first_post.message += '!'
    first_post.save()
    return HttpResponse()  # OK

async def my_endpoint_async(request):
    first_post = await Post.objects.first()
    first_post.message += '!'
    await first_post.save()
    return HttpResponse()  # OK

From user code this looks pretty clean. And it’s not too bad on the Django side either from what I can see.

I agree it looks nicer on first glance, my concern is the reliability of detecting that you’re in a synchronous or asynchronous context - that code is already not 100% reliable in some very small edge cases (e.g. ipython notebooks) so we had to add a manual override to SynchronousOnlyOperation.

That, and the idea of dynamically changing the return type of a function makes me cringe a little. Just not a massive fan, I guess?

Take an existing codebase that has no async Django code, database calls are cheap business logic CRUD calls (< 0.1ms) that already block, but you also have some external API calls that take hundreds of milliseconds. It would be worthwhile turning those API calls async, but as soon as you change your request handler to async, suddenly all your ORM code now has broken and needs changing too, and since async is infectious, the change could end up more wide-reaching than you’d like.

It effectively breaks up this issue into two parts: making the ORM innards async-safe, and making the ORM API async-capable

I’ve never seen a database respond in less than a millisecond - usually I’d budget 5ms for network transit alone and then 10 - 300ms for the query depending how complex it is. That’s super dangerous because the async scheduler is assuming that you are quickly iterating through coroutines, so even blocking for 10-20 ms is going to be bad as soon as you have a couple of threads doing it.

Oh yikes. Yeah if get_running_loop() isn’t truly a reliable way to detect whether running in “an asynchronous context” then making a hard dependency on it as a global variable would be bad news.

Yes this also came to mind - I do work on mypy after all :wink: - so I was wondering if you’d bring that up. Indeed the strategy of varying the return type based on sync vs. async creates a weird return type that isn’t readily spellable.

So I guess this puts us back in the *_async boat, from my earlier comment, which is an okay place to be.


Next orders of business:

Transactions

It feels like @transaction.atomic uses thread-local state, which isn’t going to work properly in a async-scheduled green thread. I feel like there was some kind of thread-local lookalike that was designed to work in an async context, but I don’t remember what it is off the top of my head.

Database Thread Affinity

I don’t know about Postgres or MySQL, but I know for sure that the normal driver for SQLite insists that any I/O operations you send it come from a consistent single OS thread. If async Django is multiplexing all of its green threads onto one OS thread then we’re okay here. If it is not, then we could have a problem.

Heh. I was planning to have this entire thread lead into a Django Enhancement Proposal (DEP) for implementing an async ORM, but I see there already is DEP 9 (“Async-capable Django”) which even has a section for an async ORM.

And that DEP already mentions the threading problems that I just brought up, so I think I’ll drop the threading subtopic for now.

Since there already is a DEP with a lot of existing discussion, I’ll plan to next read & consider what is already there to figure out what the best next steps are to push forward an async ORM. Be back in a bit.

1 Like

(I’m planning to disconnect from this thread for a year: Too many other plates spinning. I invite other folks who are interested in Async ORM to step in and continue the discussion.)

1 Like

From a user simplicity standpoint, I still like this. Correct me if I’m wrong but under typical usage, get_running_loop() functions as intended. So I wouldn’t pull this idea off the table, it’s still an option to bug fix edge cases of it. Plus worst case scenario, the user could either override save() or use awkwardly use _save_async if they’re running some obscure edge case where they just need to get things working.

On a tangent, I’m coming here from asgrief and channels issues. I’m seeing that the Django team had turned thread_sensitive=True in response to the ORM having “subtle bugs” when it’s False, which I suppose resulted in this thread being made. But I’m struggling to track down exactly what bugs where experienced. Is there an issue list for that somewhere for me to look at? I’d like to see if I can reproduce them in a test environment.

1 Like

Unfortunately, I don’t have an exhaustive list of what caused the problems, but generally it was of the form of “persisting a connection handle to the wrong thread”.

The specific thing that made us fix it is that, if I recall correctly, AuthMiddleware gets a connection object when you go through the middleware but assigns it to a lazy property accessor, and then resolves it later in the view when you look at request.user. If you don’t thread-pin every sync function in the call stack within a request, you might end up with the middleware and the view running in different threads and, well, that varies from potentially bad (transactions not matching) to SQLite just plain not working.

I definitely see the issue from a technical standpoint.

It’s not a bad idea to have one dedicated backhaul thread for handling all async ORM queries.

In that case, I think this could be a good opportunity to discuss whether to bring the SQL backhaul thread concept to be a constant within Django. I don’t see any major issues with pulling it into both WSGI/ASGI.

Could be a cleaner solution than scoped sync to async.

I suspect one database thread will not be enough, but yes, the initial version of “async ORM” was intended to be an async-compatible API in front of a thread system for the database, so we can get most of the benefits for user code without unrolling the entire ORM just yet.

Thinking from the perspective of WSGI with python GIL, one backhaul thread would still be a performance improvement. Would be significantly easier to develop.

At this point, I suspect anything that allows database query offloading would be an improvement, but my reasoning for multiple threads is the ability to run queries in parallel (since one of the main efficiency use cases would be to run multiple independent queries at once).

Are you thinking ASGI_THREADS number of SQL backhaul threads?

Reading through this, one thing I didn’t see mentioned would be to adopt a Celery-style strategy of opt-in asynchronicity:

   post = Post.objects.get(id=1)
   post = await Post.objects.get.awaited(id=1)

The idea being that you can either choose to call a method directly, or run it off-band. The Celery mechanism of course if f(id=1) vs f.delay(id=1).

async keyword really removes the “good” answer here though, unfortunately…

One other thing I would like to point out here is that a lot of queryset helpers would in fact be async/sync-agnostic. In that situation, it would actually be easier to let people provide synchronicity-agnostic code if this were a keyword:

def all(self, awaited=False):
     ....

def get_items(*args, awaited=False):
     return Post.objects.all(awaited=awaited)

results_sync = get_items(id=1)
results_async = await get_items(id=1, awaited=True)

There’d very likely be situations where passing in futures into other QS-returning methods could call for transparently await-ing them as wel. Hard to know how important this is when stuff isn’t “out there” yet, but I think it’s a common pattern in Django apps to have kwarg-bubbling, so to speak.

No, probably separately configurable as part of Django. We added the hooks into asgiref so you can specify custom threadpools now.

I did consider that as a calling style, but in the end I feel like objects.get and objects.get_async is a bit clearer and less magic than objects.get.deferred or similar. That overall pattern is still the plan, though, since as you say, most of the QuerySet members are lazy anyway. Only the “methods that do not need to return QuerySets” part of the docs (QuerySet API reference | Django documentation | Django) needs looking at.