Skip to content

Commit d96810a

Browse files
author
rodrigo.nogueira
committed
feat: emit AlruCacheLoopResetWarning on event loop auto-reset
Add a UserWarning subclass that fires once per cache instance when _check_loop detects an event loop change and auto-clears stale entries. This helps users notice accidental multi-loop usage in production without breaking anything. Follow-up to #743.
1 parent fff4d49 commit d96810a

2 files changed

Lines changed: 64 additions & 5 deletions

File tree

async_lru/__init__.py

Lines changed: 15 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,9 @@
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+
4751
@final
4852
class _CacheParameters(TypedDict):
4953
typed: bool
@@ -113,6 +117,7 @@ def __init__(
113117
self.__hits = 0
114118
self.__misses = 0
115119
self.__first_loop: Optional[asyncio.AbstractEventLoop] = None
120+
self.__warned_loop_reset = False
116121

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

tests/test_thread_safety.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
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+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
69
def test_cross_loop_auto_resets_cache() -> None:
710
@alru_cache(maxsize=100)
811
async def cached_func(key: str) -> str:
@@ -24,7 +27,7 @@ async def cached_func(key: str) -> str:
2427
assert cached_func.cache_info().hits == 0
2528
assert cached_func.cache_info().misses == 1
2629

27-
30+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
2831
def test_cross_loop_preserves_stats_reset() -> None:
2932
@alru_cache(maxsize=100)
3033
async def cached_func(key: str) -> str:
@@ -46,7 +49,7 @@ async def cached_func(key: str) -> str:
4649
assert cached_func.cache_info().hits == 0
4750
assert cached_func.cache_info().misses == 1
4851

49-
52+
@pytest.mark.filterwarnings("ignore::async_lru.AlruCacheLoopResetWarning")
5053
def test_invalid_key_does_not_bind_loop() -> None:
5154
@alru_cache(maxsize=100)
5255
async def cached_func(key: object) -> str:
@@ -139,7 +142,7 @@ async def run_concurrent() -> list[str]:
139142
assert results == ["data_test"] * 3
140143
assert cached_func.cache_info().hits == 2
141144

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

0 commit comments

Comments
 (0)