This issue tracker has been migrated to GitHub, and is currently read-only.
For more information, see the GitHub FAQs in the Python's Developer Guide.

classification
Title: Add an async variant of lru_cache for coroutines.
Type: enhancement Stage: patch review
Components: Library (Lib) Versions: Python 3.11
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: Nosy List: achimnol, asvetlov, rhettinger, serhiy.storchaka, uranusjr, yselivanov
Priority: normal Keywords: patch

Created on 2022-02-03 07:28 by uranusjr, last changed 2022-04-11 14:59 by admin.

Pull Requests
URL Status Linked Edit
PR 31314 closed uranusjr, 2022-02-13 13:51
PR 31495 closed uranusjr, 2022-02-22 08:55
Messages (16)
msg412422 - (view) Author: Tzu-ping Chung (uranusjr) * Date: 2022-02-03 07:28
Currently, decorating a coroutine with cached_property would cache the coroutine itself. But this is not useful in any way since a coroutine cannot be awaited multiple times.

Running this code:

    import asyncio
    import functools

    class A:
        @functools.cached_property
        async def hello(self):
            return 'yo'

    async def main():
        a = A()
        print(await a.hello)
        print(await a.hello)

    asyncio.run(main())

produces:

    yo
    Traceback (most recent call last):
      File "t.py", line 15, in <module>
        asyncio.run(main())
      File "/lib/python3.10/asyncio/runners.py", line 44, in run
        return loop.run_until_complete(main)
      File "/lib/python3.10/asyncio/base_events.py", line 641, in run_until_complete
        return future.result()
      File "t.py", line 12, in main
        print(await a.hello)
    RuntimeError: cannot reuse already awaited coroutine

The third-party cached_property package, on the other hand, detects a coroutine and caches its result instead. I feel this is a more useful behaviour. https://github.com/pydanny/cached-property/issues/85
msg412906 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-09 14:21
Pull Request is welcome!
I would say that the PR should not use asyncio directly but 'async def', 'await', and `inspect.iscoroutine()` / `inspect.iscoroutinefunction()` instead.
The property should work with any async framework, not asyncio only.
msg412985 - (view) Author: Tzu-ping Chung (uranusjr) * Date: 2022-02-10 09:23
> should not use asyncio directly but 'async def', 'await', and `inspect.iscoroutine()` / `inspect.iscoroutinefunction()` instead.

Hmm, this introduces some difficulties. Since a coroutine can only be awaited once, a new coroutine needs to be returned (that simply return the result when awaited) each time __get__ is called. But this means we need a way to somehow get the result in __get__. If there’s a separate `cached_property_async` it’s possible to make __get__ a coroutine function, but personally I’d prefer the interface to match the PyPI cached_property.
msg412987 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-10 09:46
You can return a wrapper from __get__ that awaits the inner function and saves the result somewhere.
msg412988 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2022-02-10 10:04
Something like:

_unset = ['unset']
class CachedAwaitable:
    def __init__(self, awaitable):
        self.awaitable = awaitable
        self.result = _unset
    def __await__(self):
        if self.result is _unset:
            self.result = yield from self.awaitable.__await__()
        return self.result
msg413187 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-13 15:10
I have a design question.
Does `print(await a.hello)` look awkward?
I'm not speaking about correction.

In asyncio code I have seen before, `await val` means waiting for a future object. `await func()` means async function call.

`await obj.attr` looks more like a future waiting but, in fact, it is not.
Should we encourage such syntax?
I have no strong opinion here and like to hear other devs.
msg413222 - (view) Author: Tzu-ping Chung (uranusjr) * Date: 2022-02-14 11:30
I agree that `print(await a.hello)` does look awkward, although I know some would disagree. (Context: I submitted this BPO after a colleague of mine at $WORK pointed out the behavioural difference between `functools` and `cached_property to me.)

Personally I’d feel this more natural:

class Foo:
    @functools.cache
    async def go(self):
        print(1)

async def main():
    foo = Foo()
    await foo.go()
    await foo.go()

Although now I just noticed this actually does not work either.

Perhaps we should fix this instead and add a line in the documentation under cached_property to point people to the correct path?
msg413223 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-14 13:29
Agree. Let's start from async functions support by `functools.lru_cache`. 

If we will have an agreement that cached_property is an important use-case we can return to this issue.

I have a feeling that async lru_cache is much more important. https://pypi.org/project/async_lru/ has 0.5 million downloads per month: https://pypistats.org/packages/async-lru
msg413225 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2022-02-14 14:35
Note that there is a similar issue with cached generators.

>>> from functools import *
>>> @lru_cache()
... def g():
...     yield 1
... 
>>> list(g())
[1]
>>> list(g())
[]

I am not sure that it is safe to detect awaitables and iterables in caching decorators and automatically wrap them in re-awaitable and re-iterable objects. But we can add explicit decorators and combine them with arbitrary caching decorators. For example:

@lru_cache()
@reiterable
def g():
    yield 1
msg413226 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-14 14:46
From my point of view, both sync and async functions can be cached.

Sync and async iterators/generators are other beasts: they don't return a value but generate a series of items. The series can be long and memory-consuming, I doubt if it should be cached safely.
msg413764 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2022-02-23 02:43
If this goes forward, my strong preference is to have a separate async_lru() function  just like the referenced external project.

For non-async uses, overloading the current lru_cache makes it confusing to reason about. It becomes harder to describe clearly what the caches do or to show a pure python equivalent.  People are already challenged to digest the current capabilities and are already finding it difficult to reason about when references are held.  I don't want to add complexity, expand the docs to be more voluminous, cover the new corner cases, and add examples that don't make sense to non-async users (i.e. the vast majority of python users).  Nor do I want to update the recipes for lru_cache variants to all be async aware.  So, please keep this separate (it is okay to share some of the underlying implementation, but the APIs should be distinct).

Also as a matter of fair and equitable policy, I am concerned about taking the core of a popular external project and putting in the standard library.  That would tend to kill the external project, either stealing all its users or leaving it as something that only offers a few incremental features above those in the standard library.  That is profoundly unfair to the people who created, maintained, and promoted the project.

Various SC members periodically voice a desire to move functionality *out* of the standard library and into PyPI rather than the reverse.  If a popular external package is meeting needs, perhaps it should be left alone.

As noted by the other respondants, caching sync and async iterators/generators is venturing out on thin ice.  Even if it could be done reliably (which is dubious), it is likely to be bug bait for users.  Remember, we already get people who try to cache time(), random() or other impure functions.  They cache methods and cannot understand why references is held for the instance.  Assuredly, coroutines and futures will encounter even more misunderstandings. 

Also, automatic reiterability is can of worms and would likely require a PEP. Every time subject has come up before, we've decided not to go there.  Let's not make a tool that makes user's lives worse.  Not everything should be cached.  Even if we can, it doesn't mean we should.
msg413790 - (view) Author: Andrew Svetlov (asvetlov) * (Python committer) Date: 2022-02-23 13:38
Thanks, Raymond.

I agree that caching of iterators and generators is out of the issue scope.

Also, I agree that a separate async cache decorator should be added. I prefer the `async_lru_cache` (and maybe `async_cache` for the API symmetry). We have `contextmanager` and `asynccontextmanager` in contextlib already along with `closing` / `aclosing`, `ExitStack` / `AsyncExitStack` etc.

`async_lru_cache` should have the same arguments as accepted by `lru_cache` but work with async functions.

I think this function should be a part of stdlib because the implementation shares *internal* `_lru_cache_wrapper` that does all dirty jobs (and has C accelerator). A third-party library should either copy all these implementation details or import a private function from stdlib and keep fingers crossed in hope that the private API will keep backward compatibility in future Python versions.

Similar reasons were applied to contextlib async APIs.

Third parties can have different features (time-to-live, expiration events, etc., etc.) and can be async-framework specific (work with asyncio or trio only) -- I don't care about these extensions here.

My point is: stdlib has built-in lru cache support, I love it. Let's add exactly the as we have already for sync functions but for async ones.
msg413791 - (view) Author: Tzu-ping Chung (uranusjr) * Date: 2022-02-23 13:47
Another thing to point out is that existing third-party solutions (both alru_cache and cached_property) only work for asyncio, and the stdlib version (as implemented now) will be an improvement. And if the position is that the improvements should only be submitted to third-party solutions---I would need to disagree since both lru_cache and cached_property have third-party solutions predating their stdlib implementations, and it is double-standard IMO if an async solution is kept out while the sync version is kept in.
msg413816 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2022-02-23 16:39
I think that it would be simpler to add a decorator which wraps the result of an asynchronous function into an object which can be awaited more than once:

def reawaitable(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return CachedAwaitable(func(*args, **kwargs))
    return wrapper

It can be combined with lru_cache and cached_property any third-party caching decorator. No access to internals of the cache is needed.

@lru_cache()
@reawaitable
async def coro(...):
    ...

@cached_property
@reawaitable
async def prop(self):
    ...
msg413819 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2022-02-23 16:55
async_lru_cache() and async_cached_property() can be written using that decorator. The implementation of async_lru_cache() is complicated because the interface of lru_cache() is complicated. But it is simpler than using _lru_cache_wrapper().

def async_lru_cache(maxsize=128, typed=False):
    if callable(maxsize) and isinstance(typed, bool):
        user_function, maxsize = maxsize, 128
        return lru_cache(maxsize, typed)(reawaitable(user_function))

    def decorating_function(user_function):
        return lru_cache(maxsize, typed)(reawaitable(user_function))

    return decorating_function

def async_cached_property(user_function):
    return cached_property(reawaitable(user_function))
msg413923 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2022-02-24 16:03
[Andrew Svetlov]
> A third-party library should either copy all these 
> implementation details or import a private function from stdlib 

OrderedDict provides just about everything needed to roll lru cache variants.  It simply isn't true this can only be done efficiently in the standard library.


[Serhiy]
> it would be simpler to add a decorator which wraps the result 
> of an asynchronous function into an object which can be awaited
> more than once:

This is much more sensible.

> It can be combined with lru_cache and cached_property any third-party
> caching decorator. No access to internals of the cache is needed.

Right.  The premise that this can only be done in the standard library was false.

> async_lru_cache() and async_cached_property() can be written 
> using that decorator. 

The task becomes trivially easy :-)  


[Andrew Svetlov]
> Pull Request is welcome!

ISTM it was premature to ask for a PR before an idea has been thought through.  We risk wasting a user's time or committing too early before simpler, better designed alternatives emerge.
History
Date User Action Args
2022-04-11 14:59:55adminsetgithub: 90780
2022-02-24 16:03:50rhettingersetmessages: + msg413923
2022-02-24 15:56:56achimnolsetnosy: + achimnol
2022-02-23 16:55:07serhiy.storchakasetmessages: + msg413819
2022-02-23 16:39:43serhiy.storchakasetmessages: + msg413816
2022-02-23 13:47:58uranusjrsetmessages: + msg413791
2022-02-23 13:38:46asvetlovsetmessages: + msg413790
2022-02-23 03:11:28rhettingersettitle: Add a async variant of lru_cache for coroutines. -> Add an async variant of lru_cache for coroutines.
2022-02-23 02:43:26rhettingersetmessages: + msg413764
title: Support decorating a coroutine with functools.lru_cache -> Add a async variant of lru_cache for coroutines.
2022-02-22 11:37:58asvetlovsettitle: Support decorating a coroutine with functools.cached_property -> Support decorating a coroutine with functools.lru_cache
2022-02-22 08:55:05uranusjrsetpull_requests: + pull_request29625
2022-02-14 14:46:21asvetlovsetmessages: + msg413226
2022-02-14 14:35:52serhiy.storchakasetmessages: + msg413225
2022-02-14 13:29:26asvetlovsetmessages: + msg413223
2022-02-14 11:30:00uranusjrsetmessages: + msg413222
2022-02-13 15:10:57asvetlovsetmessages: + msg413187
2022-02-13 13:51:29uranusjrsetkeywords: + patch
stage: patch review
pull_requests: + pull_request29476
2022-02-10 10:04:54serhiy.storchakasetnosy: + serhiy.storchaka
messages: + msg412988
2022-02-10 09:46:13asvetlovsetmessages: + msg412987
2022-02-10 09:23:17uranusjrsetmessages: + msg412985
2022-02-09 14:21:42asvetlovsetmessages: + msg412906
2022-02-09 13:34:39AlexWaygoodsetnosy: + rhettinger, asvetlov, yselivanov
2022-02-08 22:56:03eric.araujosetversions: - Python 3.8, Python 3.9, Python 3.10
2022-02-03 07:28:28uranusjrcreate