Skip to content

Commit 33e1a7c

Browse files
Emit AlruCacheLoopResetWarning on event loop auto-reset (#744)
1 parent fff4d49 commit 33e1a7c

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

async_lru/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import inspect
44
import random
55
import sys
6+
import warnings
67
from functools import _CacheInfo, _make_key, partial, partialmethod
78
from typing import (
89
Any,
@@ -34,7 +35,7 @@
3435

3536
__version__ = "2.2.0"
3637

37-
__all__ = ("alru_cache",)
38+
__all__ = ("AlruCacheLoopResetWarning", "alru_cache")
3839

3940

4041
_T = TypeVar("_T")
@@ -44,6 +45,10 @@
4445
_CBP = Union[_CB[_R], "partial[_Coro[_R]]", "partialmethod[_Coro[_R]]"]
4546

4647

48+
class AlruCacheLoopResetWarning(UserWarning):
49+
"""Emitted once per cache instance when a loop change triggers an auto-reset."""
50+
51+
4752
@final
4853
class _CacheParameters(TypedDict):
4954
typed: bool
@@ -113,6 +118,7 @@ def __init__(
113118
self.__hits = 0
114119
self.__misses = 0
115120
self.__first_loop: Optional[asyncio.AbstractEventLoop] = None
121+
self.__warned_loop_reset = False
116122

117123
@property
118124
def __tasks(self) -> List["asyncio.Task[_R]"]:
@@ -130,6 +136,15 @@ def _check_loop(self, loop: asyncio.AbstractEventLoop) -> None:
130136
if self.__first_loop is None:
131137
self.__first_loop = loop
132138
elif self.__first_loop is not loop:
139+
if not self.__warned_loop_reset:
140+
warnings.warn(
141+
"alru_cache detected event loop change and auto-cleared "
142+
"stale entries. This is safe but unusual outside of "
143+
"tests (pytest-anyio, etc.).",
144+
AlruCacheLoopResetWarning,
145+
stacklevel=3,
146+
)
147+
self.__warned_loop_reset = True
133148
# Old cache entries hold tasks/handles bound to the previous
134149
# loop and are invalid here. Clear and rebind.
135150
self.cache_clear()

tests/test_thread_safety.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import asyncio
2+
import warnings
23

3-
from async_lru import alru_cache
4+
import pytest
45

6+
from async_lru import AlruCacheLoopResetWarning, alru_cache
57

8+
9+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
610
def test_cross_loop_auto_resets_cache() -> None:
711
@alru_cache(maxsize=100)
812
async def cached_func(key: str) -> str:
@@ -25,6 +29,7 @@ async def cached_func(key: str) -> str:
2529
assert cached_func.cache_info().misses == 1
2630

2731

32+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
2833
def test_cross_loop_preserves_stats_reset() -> None:
2934
@alru_cache(maxsize=100)
3035
async def cached_func(key: str) -> str:
@@ -47,6 +52,7 @@ async def cached_func(key: str) -> str:
4752
assert cached_func.cache_info().misses == 1
4853

4954

55+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
5056
def test_invalid_key_does_not_bind_loop() -> None:
5157
@alru_cache(maxsize=100)
5258
async def cached_func(key: object) -> str:
@@ -140,6 +146,7 @@ async def run_concurrent() -> list[str]:
140146
assert cached_func.cache_info().hits == 2
141147

142148

149+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
143150
def test_multiple_loop_transitions() -> None:
144151
@alru_cache(maxsize=100)
145152
async def cached_func(key: str) -> str:
@@ -150,3 +157,43 @@ async def cached_func(key: str) -> str:
150157
result = loop.run_until_complete(cached_func("test"))
151158
loop.close()
152159
assert result == "data_test"
160+
161+
162+
def test_loop_change_emits_warning() -> None:
163+
@alru_cache(maxsize=100)
164+
async def cached_func(key: str) -> str:
165+
return f"data_{key}"
166+
167+
loop1 = asyncio.new_event_loop()
168+
loop1.run_until_complete(cached_func("test"))
169+
loop1.close()
170+
171+
loop2 = asyncio.new_event_loop()
172+
with warnings.catch_warnings(record=True) as w:
173+
warnings.simplefilter("always")
174+
loop2.run_until_complete(cached_func("test"))
175+
loop2.close()
176+
177+
assert len(w) == 1
178+
assert issubclass(w[0].category, AlruCacheLoopResetWarning)
179+
assert "event loop change" in str(w[0].message)
180+
181+
182+
def test_loop_change_warns_only_once() -> None:
183+
@alru_cache(maxsize=100)
184+
async def cached_func(key: str) -> str:
185+
return f"data_{key}"
186+
187+
all_warnings: list[warnings.WarningMessage] = []
188+
for _ in range(4):
189+
loop = asyncio.new_event_loop()
190+
with warnings.catch_warnings(record=True) as w:
191+
warnings.simplefilter("always")
192+
loop.run_until_complete(cached_func("test"))
193+
loop.close()
194+
all_warnings.extend(w)
195+
196+
reset_warnings = [
197+
w for w in all_warnings if issubclass(w.category, AlruCacheLoopResetWarning)
198+
]
199+
assert len(reset_warnings) == 1

0 commit comments

Comments
 (0)