From 865db046a77835c76be7de045cb58d9dfb0ebd54 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 01/28] feat: handle CancelledError - cancel if no other waiters --- async_lru/__init__.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 447e9cd..2f3e456 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -56,6 +56,7 @@ class _CacheParameters(TypedDict): class _CacheItem(Generic[_R]): fut: "asyncio.Future[_R]" later_call: Optional[asyncio.Handle] + waiters: int def cancel(self) -> None: if self.later_call is not None: @@ -205,7 +206,13 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: if cache_item is not None: self._cache_hit(key) if not cache_item.fut.done(): - return await asyncio.shield(cache_item.fut) + cache_item.waiters += 1 + try: + return await asyncio.shield(cache_item.fut) + except CancelledError: + _handle_cancelled_error(cache_item, task) + finally: + cache_item.waiters -= 1 return cache_item.fut.result() @@ -215,14 +222,21 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: self.__tasks.add(task) task.add_done_callback(partial(self._task_done_callback, fut, key)) - self.__cache[key] = _CacheItem(fut, None) + cache_item = _CacheItem(fut, None, 1) + self.__cache[key] = cache_item if self.__maxsize is not None and len(self.__cache) > self.__maxsize: dropped_key, cache_item = self.__cache.popitem(last=False) cache_item.cancel() self._cache_miss(key) - return await asyncio.shield(fut) + + try: + return await asyncio.shield(fut) + except CancelledError: + _handle_cancelled_error(cache_item, task) + finally: + cache_item.waiters -= 1 def __get__( self, instance: _T, owner: Optional[Type[_T]] @@ -233,6 +247,13 @@ def __get__( return _LRUCacheWrapperInstanceMethod(self, instance) +def _handle_cancelled_error(cache_item: _CacheItem, task: asyncio.Task[Any]) -> None: + if cache_item.waiters == 1 and not task.done(): + task.cancel() + cache_item.cancel() + self.__cache.pop(key) + + @final class _LRUCacheWrapperInstanceMethod(Generic[_R, _T]): def __init__( From 9244bc7ce0b5553bc80df84df570d2a6a01bbb9a Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 02/28] Update __init__.py --- async_lru/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 2f3e456..a386957 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -211,6 +211,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: return await asyncio.shield(cache_item.fut) except CancelledError: _handle_cancelled_error(cache_item, task) + raise finally: cache_item.waiters -= 1 @@ -235,6 +236,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: return await asyncio.shield(fut) except CancelledError: _handle_cancelled_error(cache_item, task) + raise finally: cache_item.waiters -= 1 From 8c2b878091983723fd80b222c9d28670b2eba208 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 03/28] Create test_cancel.py --- tests/test_cancel.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/test_cancel.py diff --git a/tests/test_cancel.py b/tests/test_cancel.py new file mode 100644 index 0000000..cf121a8 --- /dev/null +++ b/tests/test_cancel.py @@ -0,0 +1,27 @@ +import asyncio + +from async_lru import alru_cache + + +@pytest.mark.parametrize("num_to_cancel", [0, 1, 2, 3]) +async def test_cancel(num_to_cancel: int) -> None: + cache_item_task_finished = False + + @alru_cache + async def coro(val: int) -> int: + nonlocal cache_item_task_finished + await asyncio.sleep(2) + cache_item_task_finished = True + return val + + tasks = [asyncio.create_task(coro()) for _ in range(3)] + + # force the event loop to run once so the tasks can begin + await asyncio.sleep(0) + + for i in range(num_to_cancel): + tasks[i].cancel() + + await asyncio.sleep(3) + + assert cache_item_task_finished is num_to_cancel < 3 From b283118f74ee2b26369b2a2f788e8fe0c108e328 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 04/28] Update test_cancel.py --- tests/test_cancel.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_cancel.py b/tests/test_cancel.py index cf121a8..b04a1c5 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -6,22 +6,29 @@ @pytest.mark.parametrize("num_to_cancel", [0, 1, 2, 3]) async def test_cancel(num_to_cancel: int) -> None: cache_item_task_finished = False - + @alru_cache async def coro(val: int) -> int: + # I am a long running coro function nonlocal cache_item_task_finished await asyncio.sleep(2) cache_item_task_finished = True return val - tasks = [asyncio.create_task(coro()) for _ in range(3)] + # create 3 tasks for the cached function using the same key + tasks = [asyncio.create_task(coro(1)) for _ in range(3)] # force the event loop to run once so the tasks can begin await asyncio.sleep(0) + # maybe cancel some tasks for i in range(num_to_cancel): tasks[i].cancel() + # allow enough time for the non-cancelled tasks to complete await asyncio.sleep(3) + # check state assert cache_item_task_finished is num_to_cancel < 3 + assert all(task.cancelled() for task in tasks[:num_to_cancel]) + assert all(task.finished() for task in tasks[num_to_cancel:]) From f8ceb969c1a69a33e0139098744c125c3756167b Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 05/28] Update test_cancel.py --- tests/test_cancel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cancel.py b/tests/test_cancel.py index b04a1c5..aa0c615 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -1,5 +1,7 @@ import asyncio +import pytest + from async_lru import alru_cache From 27100f355cb787175d649144c1ecc060bdfa253f Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:52:05 +0000 Subject: [PATCH 06/28] fix: name error CancelledError --- async_lru/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index a386957..255c597 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -209,7 +209,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: cache_item.waiters += 1 try: return await asyncio.shield(cache_item.fut) - except CancelledError: + except asyncio.CancelledError: _handle_cancelled_error(cache_item, task) raise finally: @@ -234,7 +234,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: try: return await asyncio.shield(fut) - except CancelledError: + except asyncio.CancelledError: _handle_cancelled_error(cache_item, task) raise finally: From 19b1b3b06cd1b0a46b69e7d3deb476abbdd0a7d7 Mon Sep 17 00:00:00 2001 From: BobTheBuidler Date: Sun, 2 Nov 2025 17:14:54 +0000 Subject: [PATCH 07/28] feat(test): more cancel tests --- tests/test_cancel.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/test_cancel.py b/tests/test_cancel.py index aa0c615..84f820b 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -1,10 +1,7 @@ import asyncio - import pytest - from async_lru import alru_cache - @pytest.mark.parametrize("num_to_cancel", [0, 1, 2, 3]) async def test_cancel(num_to_cancel: int) -> None: cache_item_task_finished = False @@ -31,6 +28,27 @@ async def coro(val: int) -> int: await asyncio.sleep(3) # check state - assert cache_item_task_finished is num_to_cancel < 3 - assert all(task.cancelled() for task in tasks[:num_to_cancel]) - assert all(task.finished() for task in tasks[num_to_cancel:]) + assert cache_item_task_finished == (num_to_cancel < 3) + +@pytest.mark.asyncio +async def test_cancel_single_waiter_triggers_handle_cancelled_error(): + # This test ensures the _handle_cancelled_error path (waiters == 1) is exercised. + cache_item_task_finished = False + + @alru_cache + async def coro(val: int) -> int: + nonlocal cache_item_task_finished + await asyncio.sleep(2) + cache_item_task_finished = True + return val + + task = asyncio.create_task(coro(42)) + await asyncio.sleep(0) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # The underlying coroutine should be cancelled, so the flag should remain False + assert cache_item_task_finished is False From e85e0996420a1351991d51f870467ee53d4fdd48 Mon Sep 17 00:00:00 2001 From: BobTheBuidler Date: Sun, 2 Nov 2025 17:21:54 +0000 Subject: [PATCH 08/28] finish up impl --- async_lru/__init__.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 255c597..1002ee5 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -57,6 +57,7 @@ class _CacheItem(Generic[_R]): fut: "asyncio.Future[_R]" later_call: Optional[asyncio.Handle] waiters: int + task: "asyncio.Task[_R]" def cancel(self) -> None: if self.later_call is not None: @@ -193,7 +194,17 @@ def _task_done_callback( fut.set_result(task.result()) + def _handle_cancelled_error(self, key: Hashable, cache_item: "_CacheItem") -> None: + # Called when a waiter is cancelled. + # If this is the last waiter and the underlying task is not done, + # cancel the underlying task and remove the cache entry. + if cache_item.waiters == 1 and not cache_item.task.done(): + cache_item.cancel() # Cancel TTL expiration + cache_item.task.cancel() # Cancel the running coroutine + self.__cache.pop(key, None) # Remove from cache + async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: + # Main entry point for cached coroutine calls. if self.__closed: raise RuntimeError(f"alru_cache is closed for {self}") @@ -206,15 +217,21 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: if cache_item is not None: self._cache_hit(key) if not cache_item.fut.done(): + + # Each logical waiter increments waiters on entry. cache_item.waiters += 1 + try: + # All waiters await the same future. return await asyncio.shield(cache_item.fut) except asyncio.CancelledError: - _handle_cancelled_error(cache_item, task) + # If a waiter is cancelled, handle possible last-waiter cleanup. + self._handle_cancelled_error(key, cache_item) raise finally: + # Each logical waiter decrements waiters on exit (normal or cancelled). cache_item.waiters -= 1 - + # If the future is already done, just return the result. return cache_item.fut.result() fut = loop.create_future() @@ -223,19 +240,19 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: self.__tasks.add(task) task.add_done_callback(partial(self._task_done_callback, fut, key)) - cache_item = _CacheItem(fut, None, 1) + cache_item = _CacheItem(fut, None, 1, task) self.__cache[key] = cache_item if self.__maxsize is not None and len(self.__cache) > self.__maxsize: - dropped_key, cache_item = self.__cache.popitem(last=False) - cache_item.cancel() + dropped_key, dropped_cache_item = self.__cache.popitem(last=False) + dropped_cache_item.cancel() self._cache_miss(key) try: return await asyncio.shield(fut) except asyncio.CancelledError: - _handle_cancelled_error(cache_item, task) + self._handle_cancelled_error(key, cache_item) raise finally: cache_item.waiters -= 1 @@ -249,13 +266,6 @@ def __get__( return _LRUCacheWrapperInstanceMethod(self, instance) -def _handle_cancelled_error(cache_item: _CacheItem, task: asyncio.Task[Any]) -> None: - if cache_item.waiters == 1 and not task.done(): - task.cancel() - cache_item.cancel() - self.__cache.pop(key) - - @final class _LRUCacheWrapperInstanceMethod(Generic[_R, _T]): def __init__( From 2c4d654259d366ca22b80104a61676c8d7bdead1 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 13:32:37 -0400 Subject: [PATCH 09/28] Update test_cancel.py --- tests/test_cancel.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_cancel.py b/tests/test_cancel.py index 84f820b..8dc1e96 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -1,7 +1,10 @@ import asyncio + import pytest + from async_lru import alru_cache + @pytest.mark.parametrize("num_to_cancel", [0, 1, 2, 3]) async def test_cancel(num_to_cancel: int) -> None: cache_item_task_finished = False @@ -30,6 +33,7 @@ async def coro(val: int) -> int: # check state assert cache_item_task_finished == (num_to_cancel < 3) + @pytest.mark.asyncio async def test_cancel_single_waiter_triggers_handle_cancelled_error(): # This test ensures the _handle_cancelled_error path (waiters == 1) is exercised. From f9cfbf954dcc773800910dcf37a903cc7dc4793c Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:01:07 -0400 Subject: [PATCH 10/28] lint --- async_lru/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 1002ee5..75dc289 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -220,7 +220,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: # Each logical waiter increments waiters on entry. cache_item.waiters += 1 - + try: # All waiters await the same future. return await asyncio.shield(cache_item.fut) From 6441a1d1f5bb1fd7eb4780e2a74d5f1f90bce90b Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:07:10 -0400 Subject: [PATCH 11/28] Update __init__.py --- async_lru/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 75dc289..7bc1739 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -217,7 +217,6 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: if cache_item is not None: self._cache_hit(key) if not cache_item.fut.done(): - # Each logical waiter increments waiters on entry. cache_item.waiters += 1 From 94c303bb6b2567a116784208447f4210211dff23 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:36:43 -0400 Subject: [PATCH 12/28] Update __init__.py --- async_lru/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 7bc1739..aadb96e 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -204,7 +204,6 @@ def _handle_cancelled_error(self, key: Hashable, cache_item: "_CacheItem") -> No self.__cache.pop(key, None) # Remove from cache async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: - # Main entry point for cached coroutine calls. if self.__closed: raise RuntimeError(f"alru_cache is closed for {self}") From 1dec1b4597affd5ebd7f54275fb64dbbccb4ce93 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:50:16 -0400 Subject: [PATCH 13/28] chore: refactor out __task and fut --- async_lru/__init__.py | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index aadb96e..f30a405 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -54,10 +54,9 @@ class _CacheParameters(TypedDict): @final @dataclasses.dataclass class _CacheItem(Generic[_R]): - fut: "asyncio.Future[_R]" + task: "asyncio.Task[_R]" later_call: Optional[asyncio.Handle] waiters: int - task: "asyncio.Task[_R]" def cancel(self) -> None: if self.later_call is not None: @@ -170,18 +169,9 @@ def _cache_miss(self, key: Hashable) -> None: self.__misses += 1 def _task_done_callback( - self, fut: "asyncio.Future[_R]", key: Hashable, task: "asyncio.Task[_R]" + self, key: Hashable, task: "asyncio.Task[_R]" ) -> None: - self.__tasks.discard(task) - - if task.cancelled(): - fut.cancel() - self.__cache.pop(key, None) - return - - exc = task.exception() - if exc is not None: - fut.set_exception(exc) + if task.cancelled() or task.exception() is not None: self.__cache.pop(key, None) return @@ -192,8 +182,6 @@ def _task_done_callback( self.__ttl, self.__cache.pop, key, None ) - fut.set_result(task.result()) - def _handle_cancelled_error(self, key: Hashable, cache_item: "_CacheItem") -> None: # Called when a waiter is cancelled. # If this is the last waiter and the underlying task is not done, @@ -215,13 +203,13 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: if cache_item is not None: self._cache_hit(key) - if not cache_item.fut.done(): + if not cache_item.task.done(): # Each logical waiter increments waiters on entry. cache_item.waiters += 1 try: - # All waiters await the same future. - return await asyncio.shield(cache_item.fut) + # All waiters await the same shielded task. + return await asyncio.shield(cache_item.task) except asyncio.CancelledError: # If a waiter is cancelled, handle possible last-waiter cleanup. self._handle_cancelled_error(key, cache_item) @@ -229,16 +217,15 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: finally: # Each logical waiter decrements waiters on exit (normal or cancelled). cache_item.waiters -= 1 - # If the future is already done, just return the result. - return cache_item.fut.result() + # If the task is already done, just return the result. + return cache_item.task.result() - fut = loop.create_future() coro = self.__wrapped__(*fn_args, **fn_kwargs) task: asyncio.Task[_R] = loop.create_task(coro) self.__tasks.add(task) - task.add_done_callback(partial(self._task_done_callback, fut, key)) + task.add_done_callback(partial(self._task_done_callback, key)) - cache_item = _CacheItem(fut, None, 1, task) + cache_item = _CacheItem(task, None, 1) self.__cache[key] = cache_item if self.__maxsize is not None and len(self.__cache) > self.__maxsize: @@ -248,7 +235,7 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: self._cache_miss(key) try: - return await asyncio.shield(fut) + return await asyncio.shield(task) except asyncio.CancelledError: self._handle_cancelled_error(key, cache_item) raise From 85f2f51855d8e56dd0c3a7c8dbb878df16ee46d2 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:54:55 -0400 Subject: [PATCH 14/28] Update __init__.py --- async_lru/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index f30a405..0f64b52 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -109,7 +109,11 @@ def __init__( self.__closed = False self.__hits = 0 self.__misses = 0 - self.__tasks: Set["asyncio.Task[_R]"] = set() + + @property + def __tasks(self) -> List["asyncio.Task[_R]"]: + # NOTE: I don't think we need to form a set first here but not too sure we want it for guarantees + return list({cache_item.task for cache_item in self.__cache if not cache_item.task.done()}] def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: key = _make_key(args, kwargs, self.__typed) @@ -129,12 +133,11 @@ def cache_clear(self) -> None: if c.later_call: c.later_call.cancel() self.__cache.clear() - self.__tasks.clear() async def cache_close(self, *, wait: bool = False) -> None: self.__closed = True - tasks = list(self.__tasks) + tasks = self.__tasks if not tasks: return @@ -222,7 +225,6 @@ async def __call__(self, /, *fn_args: Any, **fn_kwargs: Any) -> _R: coro = self.__wrapped__(*fn_args, **fn_kwargs) task: asyncio.Task[_R] = loop.create_task(coro) - self.__tasks.add(task) task.add_done_callback(partial(self._task_done_callback, key)) cache_item = _CacheItem(task, None, 1) From 094a3cc33a1188e2a8eabaa57bdff525c6c4cf41 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:05:26 -0400 Subject: [PATCH 15/28] Update __init__.py --- async_lru/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 0f64b52..39483c2 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -113,7 +113,7 @@ def __init__( @property def __tasks(self) -> List["asyncio.Task[_R]"]: # NOTE: I don't think we need to form a set first here but not too sure we want it for guarantees - return list({cache_item.task for cache_item in self.__cache if not cache_item.task.done()}] + return list({cache_item.task for cache_item in self.__cache if not cache_item.task.done()}) def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: key = _make_key(args, kwargs, self.__typed) From 0b06d9b4e45af39fa5d40aa4462718dddbd88aeb Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:09:53 -0400 Subject: [PATCH 16/28] fix missing import --- async_lru/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 39483c2..aeb7d0f 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -11,6 +11,7 @@ Hashable, Optional, OrderedDict, + List, Set, Type, TypedDict, From b439ecac4bf2a31f00ceac4a527a58c5b6daf5b6 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:12:23 -0400 Subject: [PATCH 17/28] Update __init__.py --- async_lru/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index aeb7d0f..68a4699 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -114,7 +114,7 @@ def __init__( @property def __tasks(self) -> List["asyncio.Task[_R]"]: # NOTE: I don't think we need to form a set first here but not too sure we want it for guarantees - return list({cache_item.task for cache_item in self.__cache if not cache_item.task.done()}) + return list({cache_item.task for cache_item in self.__cache.values() if not cache_item.task.done()}) def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: key = _make_key(args, kwargs, self.__typed) From f2152ee03e7f8dee088d7757e9ac017ac8c166f5 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:16:11 -0400 Subject: [PATCH 18/28] Update __init__.py --- async_lru/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 68a4699..39faee0 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -186,7 +186,9 @@ def _task_done_callback( self.__ttl, self.__cache.pop, key, None ) - def _handle_cancelled_error(self, key: Hashable, cache_item: "_CacheItem") -> None: + def _handle_cancelled_error( + self, key: Hashable, cache_item: "_CacheItem[Any]" + ) -> None: # Called when a waiter is cancelled. # If this is the last waiter and the underlying task is not done, # cancel the underlying task and remove the cache entry. From 4bbab7478c8a310bde95eecb3af6ad1a702e69c6 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:16:27 -0400 Subject: [PATCH 19/28] Update test_cancel.py --- tests/test_cancel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cancel.py b/tests/test_cancel.py index 8dc1e96..ee405c7 100644 --- a/tests/test_cancel.py +++ b/tests/test_cancel.py @@ -35,7 +35,7 @@ async def coro(val: int) -> int: @pytest.mark.asyncio -async def test_cancel_single_waiter_triggers_handle_cancelled_error(): +async def test_cancel_single_waiter_triggers_handle_cancelled_error() -> None: # This test ensures the _handle_cancelled_error path (waiters == 1) is exercised. cache_item_task_finished = False From 157c7d5aa4b6ff59cb6c27b8323344890cb2b838 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:26:21 -0400 Subject: [PATCH 20/28] Update benchmark.py --- benchmark.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/benchmark.py b/benchmark.py index b01e0e8..65b1971 100644 --- a/benchmark.py +++ b/benchmark.py @@ -305,11 +305,10 @@ async def dummy_coro(): pass iterations = range(1000) - create_future = loop.create_future callback_fn = func._task_done_callback @benchmark def run() -> None: for i in iterations: - callback = partial(callback_fn, create_future(), i) + callback = partial(callback_fn, i) callback(task) From 23daa05f362159b3d7eebfea92336c7c91222d05 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:33:59 -0400 Subject: [PATCH 21/28] Update test_internals.py --- tests/test_internals.py | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/tests/test_internals.py b/tests/test_internals.py index e5a055c..41cb120 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -16,13 +16,12 @@ async def test_done_callback_cancelled() -> None: key = 1 task.add_done_callback(partial(wrapped._task_done_callback, fut, key)) - wrapped._LRUCacheWrapper__tasks.add(task) # type: ignore[attr-defined] task.cancel() await asyncio.sleep(0) - assert fut.cancelled() + assert task not in wrapped._LRUCacheWrapper__tasks # type: ignore[attr-defined] async def test_done_callback_exception() -> None: @@ -42,31 +41,7 @@ async def test_done_callback_exception() -> None: await asyncio.sleep(0) - with pytest.raises(ZeroDivisionError): - await fut - - with pytest.raises(ZeroDivisionError): - fut.result() - - assert fut.exception() is exc - - -async def test_done_callback() -> None: - wrapped = _LRUCacheWrapper(mock.ANY, None, False, None) - loop = asyncio.get_running_loop() - task = loop.create_future() - - key = 1 - fut = loop.create_future() - - task.add_done_callback(partial(wrapped._task_done_callback, fut, key)) - wrapped._LRUCacheWrapper__tasks.add(task) # type: ignore[attr-defined] - - task.set_result(1) - - await asyncio.sleep(0) - - assert fut.result() == 1 + assert task not in wrapped._LRUCacheWrapper__tasks # type: ignore[attr-defined] async def test_cache_invalidate_typed() -> None: From 3972c0fba8b8418d76c65989bf2aabd07c55a062 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:34:29 -0400 Subject: [PATCH 22/28] Update test_basic.py --- tests/test_basic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_basic.py b/tests/test_basic.py index ef234f0..06ddbd5 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -152,8 +152,8 @@ async def coro(val: int) -> int: assert ret1 == ret2 assert ( - coro1._LRUCacheWrapper__cache[1].fut.result() # type: ignore[attr-defined] - == coro2._LRUCacheWrapper__cache[1].fut.result() # type: ignore[attr-defined] + coro1._LRUCacheWrapper__cache[1].task.result() # type: ignore[attr-defined] + == coro2._LRUCacheWrapper__cache[1].task.result() # type: ignore[attr-defined] ) assert coro1._LRUCacheWrapper__cache != coro2._LRUCacheWrapper__cache # type: ignore[attr-defined] assert coro1._LRUCacheWrapper__cache.keys() == coro2._LRUCacheWrapper__cache.keys() # type: ignore[attr-defined] From 4878867e8cee37f54eab79d1aba621462b8bf1bc Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:38:54 -0400 Subject: [PATCH 23/28] Update test_internals.py --- tests/test_internals.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_internals.py b/tests/test_internals.py index 41cb120..1f37bfa 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -33,7 +33,6 @@ async def test_done_callback_exception() -> None: key = 1 task.add_done_callback(partial(wrapped._task_done_callback, fut, key)) - wrapped._LRUCacheWrapper__tasks.add(task) # type: ignore[attr-defined] exc = ZeroDivisionError() From 39435555afc1c2d2e267fe05f8274a5d7a0393ec Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:41:04 -0400 Subject: [PATCH 24/28] Update __init__.py --- async_lru/__init__.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 39faee0..e5bffd0 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -114,7 +114,13 @@ def __init__( @property def __tasks(self) -> List["asyncio.Task[_R]"]: # NOTE: I don't think we need to form a set first here but not too sure we want it for guarantees - return list({cache_item.task for cache_item in self.__cache.values() if not cache_item.task.done()}) + return list( + { + cache_item.task + for cache_item in self.__cache.values() + if not cache_item.task.done() + } + ) def cache_invalidate(self, /, *args: Hashable, **kwargs: Any) -> bool: key = _make_key(args, kwargs, self.__typed) @@ -172,9 +178,7 @@ def _cache_hit(self, key: Hashable) -> None: def _cache_miss(self, key: Hashable) -> None: self.__misses += 1 - def _task_done_callback( - self, key: Hashable, task: "asyncio.Task[_R]" - ) -> None: + def _task_done_callback(self, key: Hashable, task: "asyncio.Task[_R]") -> None: if task.cancelled() or task.exception() is not None: self.__cache.pop(key, None) return From d76b5374cf653abf6cfc9de0dedd3febacc9f413 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:41:25 -0400 Subject: [PATCH 25/28] Update __init__.py --- async_lru/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index e5bffd0..2b37f4f 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -9,9 +9,9 @@ Coroutine, Generic, Hashable, + List, Optional, OrderedDict, - List, Set, Type, TypedDict, From 9ed3a42d6b8ca90a7d7bcb4e3498d4192214c076 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:44:28 -0400 Subject: [PATCH 26/28] Update __init__.py --- async_lru/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/async_lru/__init__.py b/async_lru/__init__.py index 2b37f4f..b712c75 100644 --- a/async_lru/__init__.py +++ b/async_lru/__init__.py @@ -12,7 +12,6 @@ List, Optional, OrderedDict, - Set, Type, TypedDict, TypeVar, From 4675c7a9b1d6c05c2fd0998d553a17f37d1ae162 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:49:01 -0400 Subject: [PATCH 27/28] Update test_internals.py --- tests/test_internals.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_internals.py b/tests/test_internals.py index 1f37bfa..d81d82d 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -11,11 +11,10 @@ async def test_done_callback_cancelled() -> None: wrapped = _LRUCacheWrapper(mock.ANY, None, False, None) loop = asyncio.get_running_loop() task = loop.create_future() - fut = loop.create_future() key = 1 - task.add_done_callback(partial(wrapped._task_done_callback, fut, key)) + task.add_done_callback(partial(wrapped._task_done_callback, key)) task.cancel() @@ -32,7 +31,7 @@ async def test_done_callback_exception() -> None: key = 1 - task.add_done_callback(partial(wrapped._task_done_callback, fut, key)) + task.add_done_callback(partial(wrapped._task_done_callback, key)) exc = ZeroDivisionError() From 33e5dca8b5296e0c023df0d816b89961e9c0a482 Mon Sep 17 00:00:00 2001 From: BobTheBuidler <70677534+BobTheBuidler@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:52:06 -0400 Subject: [PATCH 28/28] Update test_internals.py --- tests/test_internals.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_internals.py b/tests/test_internals.py index d81d82d..cd63c09 100644 --- a/tests/test_internals.py +++ b/tests/test_internals.py @@ -27,7 +27,6 @@ async def test_done_callback_exception() -> None: wrapped = _LRUCacheWrapper(mock.ANY, None, False, None) loop = asyncio.get_running_loop() task = loop.create_future() - fut = loop.create_future() key = 1