Skip to content

Commit ce04dc1

Browse files
authored
fix(tasks): clean up singleflight cache on cancellation (#6174)
Signed-off-by: daixiheguu <daixihegu@outlook.com>
1 parent 5154bae commit ce04dc1

2 files changed

Lines changed: 101 additions & 4 deletions

File tree

src/task_scheduler.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,30 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
8484
pending = fut
8585
owner = True
8686
if not owner:
87-
return await pending
87+
# A cancelled waiter must not cancel the shared Future for the owner
88+
# and every other waiter.
89+
return await asyncio.shield(pending)
8890
try:
8991
val = await fetch()
9092
async with _shared_cache_lock:
9193
_shared_cache[key] = (time.monotonic() + ttl, val)
92-
_shared_cache_pending.pop(key, None)
9394
pending.set_result(val)
9495
return val
96+
except asyncio.CancelledError:
97+
# Cancellation is a BaseException on supported Python versions, so it
98+
# bypasses the Exception handler below. Wake all current waiters while
99+
# allowing a later caller to retry the fetch.
100+
pending.cancel()
101+
raise
95102
except Exception as e:
96-
async with _shared_cache_lock:
97-
_shared_cache_pending.pop(key, None)
98103
pending.set_exception(e)
99104
raise
105+
finally:
106+
# Keep this cleanup synchronous so a second cancellation cannot
107+
# interrupt it and leave a permanently pending Future behind. All
108+
# access runs on the scheduler's event-loop thread.
109+
if _shared_cache_pending.get(key) is pending:
110+
_shared_cache_pending.pop(key, None)
100111

101112

102113
def compute_next_run(schedule: str, scheduled_time: str,

tests/test_task_scheduler_cache.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import asyncio
2+
3+
import pytest
4+
5+
from src import task_scheduler
6+
7+
8+
@pytest.fixture(autouse=True)
9+
def clear_shared_cache():
10+
task_scheduler._shared_cache.clear()
11+
task_scheduler._shared_cache_pending.clear()
12+
yield
13+
task_scheduler._shared_cache.clear()
14+
task_scheduler._shared_cache_pending.clear()
15+
16+
17+
async def test_cached_owner_cancellation_wakes_waiters_and_allows_retry():
18+
key = ("cancelled-owner",)
19+
fetch_started = asyncio.Event()
20+
21+
async def blocked_fetch():
22+
fetch_started.set()
23+
await asyncio.Event().wait()
24+
25+
owner = asyncio.create_task(task_scheduler._cached(key, 60, blocked_fetch))
26+
await fetch_started.wait()
27+
28+
async def unexpected_fetch():
29+
pytest.fail("a waiter must share the owner's fetch")
30+
31+
waiter = asyncio.create_task(task_scheduler._cached(key, 60, unexpected_fetch))
32+
await asyncio.sleep(0)
33+
34+
owner.cancel()
35+
with pytest.raises(asyncio.CancelledError):
36+
await owner
37+
with pytest.raises(asyncio.CancelledError):
38+
await asyncio.wait_for(waiter, timeout=1)
39+
40+
assert key not in task_scheduler._shared_cache_pending
41+
42+
async def retry_fetch():
43+
return "fresh"
44+
45+
result = await asyncio.wait_for(
46+
task_scheduler._cached(key, 60, retry_fetch),
47+
timeout=1,
48+
)
49+
assert result == "fresh"
50+
51+
52+
async def test_cached_waiter_cancellation_does_not_cancel_shared_fetch():
53+
key = ("cancelled-waiter",)
54+
fetch_started = asyncio.Event()
55+
release_fetch = asyncio.Event()
56+
57+
async def blocked_fetch():
58+
fetch_started.set()
59+
await release_fetch.wait()
60+
return "shared"
61+
62+
owner = asyncio.create_task(task_scheduler._cached(key, 60, blocked_fetch))
63+
await fetch_started.wait()
64+
65+
async def unexpected_fetch():
66+
pytest.fail("a waiter must share the owner's fetch")
67+
68+
waiter = asyncio.create_task(task_scheduler._cached(key, 60, unexpected_fetch))
69+
await asyncio.sleep(0)
70+
waiter.cancel()
71+
72+
with pytest.raises(asyncio.CancelledError):
73+
await waiter
74+
75+
pending = task_scheduler._shared_cache_pending[key]
76+
assert not pending.cancelled()
77+
assert not owner.done()
78+
79+
release_fetch.set()
80+
assert await asyncio.wait_for(owner, timeout=1) == "shared"
81+
assert key not in task_scheduler._shared_cache_pending
82+
83+
async def cache_miss():
84+
pytest.fail("the successful owner result should be cached")
85+
86+
assert await task_scheduler._cached(key, 60, cache_miss) == "shared"

0 commit comments

Comments
 (0)