Proposals For Forwards/Backwards Compatibility With Async Model Methods

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.

2 Likes

I think my preference is to do a bit of a hybrid of some of the ideas listed:

  1. Put it in the release notes
  2. Auto-Implement fallback behavior (I think this is a clever idea, good thinking)
  3. Issue a warning if (2) is being done

I think it makes sense to model this as a phased rollout:

  1. Do it automatically (we are here right now, effectively)
  2. Do it and issue a warning that it’s happening
  3. Assuming nothing has gone wrong in the previous releases, start issuing a deprecation warning
  4. Fail if detected
  5. Then finally, no longer supported or special cased

I’m unsure about (4), maybe we just jump straight to (5). But my thinking is that we’d rather fail-closed in cases of possible data integrity issues.

1 Like