This is part of me trying to generate consensus on “completing” DEP 0009. See also this DEP proposal for implementation questions.
The following is about how to handle backwards compatibility for users when changing our async variants to no longer call the sync variants.
Currently Model.asave
is defined as follows (simplified):
async def asave(
self,
force_insert=False,
force_update=False,
using=None,
update_fields=None,
):
return await sync_to_async(self.save)(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)
If a Model
subclass overrides save
, then that subclass’s asave
behavior will also use that overridden behavior.
But an async-native asave
poses a problem. We won’t be able to just call save
anymore (since somewhere in the stack we’ll want to call some async-native DB method instead of a sync variant). So at one point we want it to be something like:
async def asave(self, *args, **kwargs):
# no call to save anymore!
await call_some_async_things(*args, **kwargs)
But existing code would have an overridden save
, successfully be able to use asave
with the modified behavior in current Django, but suddenly see breakage in the “Django has native asave
now” release, if asave
wasn’t also overridden beforehand!
The canonical example for me is people who override save
to set a last_updated
timestamp on their model.
This is also true for more or less every model method with async variants that currently just call out to their sync variants. This feels like a major potential footgun for Django releases, which I think is usually very good about deprecation periods.
In prepping to move to “proper” async variants for model methods, here are some ideas to help users get their code to be forwards compatible with “proper async model methods”. These are not mutually exclusive, and I believe this work can be done before the async model methods exist properly (in the same way that people can call asave
now even though it just calls back into save
).
I say “save
and asave
” below but this is true for all sync/async model methods where the current implementation is “just call sync_to_async
”.
System Check
We could write a system check on Model
to check whether a model class overrides save
(or any other sync variant) without overriding the async variant. Point being that checks would point out that there’s potential breakage in a future Django version that no longer calls save
in asave
.
I believe it would also make sense to check for “only async variant is overridden” as well, though it’s less of a backwards compatibility issue. We want full sync/async compatibility in the long term, and that includes for a user’s internal code/third party libraries. Otherwise there will never be a point that we would feel comfortable using async variants in, say, admin code.
Deprecation Warning
A bit more powerful version of the above. Have a deprecation warning on class instantiation that would confirm whether we are only overriding the sync variant without overriding the async variant (or vice versa). Main issue is that it’s “yet another thing” that Django needs to do on startup, compared to a check which would be isolatable, cost-wise.
Fallback Helper
A new descriptor in asgiref
(asgiref.sync.fallback_to_sync_variant
) could be added to allow for the following:
class MyModel(Model):
def save(self, *args, **kwargs):
...
asave = fallback_to_sync_variant
The basic idea here being that fallback_to_sync_variant
would be usable as a general “do this if you don’t know what to do”. It would also provide a place where we could add warnings to indicate “you are falling back to the sync variant here, your call is not fully asynchronous”. Right now sync_to_async
could just be hidden in your model stack somewhere and you’d be none the wiser.
I think one would also add fallback_to_async_variant
as another descriptor in the other direction.
Auto-implement fallback behavior
If a model subclass implements save
without overriding asave
, we could have asave
for that subclass fallback to sync_to_async(self.save)
, on the assumption that we would be missing important behavioral changes if we just went down the async-native path. Same in the other direction.
This would provide for maximum backwards compatibility, though would reduce the pressure for third party libs to properly implement asave
when needed.
Do “Nothing”
Release notes say “asave
no longer calls save
. If you override save
make sure to override asave
with the same functionality”.
Feels very risky to me, because someone could be in “sync variant APIs only” land for a very long time, and if (for example) the admin starts calling async variants instead, there would be some nasty bugs that users might not notice until several releases down the line.
If I decided everything right now, I’d be pretty partial to “add a helper in asgiref
” + “add a system check”. I really like the idea of auto-implementing fallback behavior in that it prioritizes the most important thing here (we must not corrupt user data), and I feel like middleware had something similar to deal with sync/async… but it is magical in a way the rest of the options aren’t.
Anyways, curious about what everyone thinks about this.