Skip to content

Commit ddfe3aa

Browse files
committed
Use monotonic clocks for result timeouts
1 parent f08a54a commit ddfe3aa

3 files changed

Lines changed: 134 additions & 6 deletions

File tree

‎taskiq/funcs.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import asyncio
2-
from time import time
2+
from time import monotonic
33
from typing import Any
44

55
from taskiq.exceptions import TaskiqResultTimeoutError
@@ -30,7 +30,7 @@ async def gather(
3030
:return: list of TaskiqResults.
3131
"""
3232
loop = asyncio.get_event_loop()
33-
start_time = time()
33+
start_time = monotonic()
3434
ordered_ids = [task.task_id for task in tasks]
3535
task_ids = {task.task_id for task in tasks}
3636
results = {}
@@ -46,7 +46,7 @@ async def check_task(task: AsyncTaskiqTask[Any]) -> None:
4646
results[task.task_id] = await task.get_result(with_logs=with_logs)
4747

4848
while task_ids:
49-
if 0 < timeout < time() - start_time:
49+
if 0 < timeout < monotonic() - start_time:
5050
raise TaskiqResultTimeoutError(timeout=timeout)
5151
check_tasks = []
5252
for task in tasks:

‎taskiq/task.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
from logging import getLogger
3-
from time import time
3+
from time import monotonic
44
from typing import TYPE_CHECKING, Any, Generic, TypeVar
55

66
from taskiq.compat import parse_obj_as
@@ -96,9 +96,9 @@ async def wait_result(
9696
become ready in provided period of time.
9797
:return: task's return value.
9898
"""
99-
start_time = time()
99+
start_time = monotonic()
100100
while not await self.is_ready():
101-
if 0 < timeout < time() - start_time:
101+
if 0 < timeout < monotonic() - start_time:
102102
raise TaskiqResultTimeoutError(timeout=timeout)
103103
await asyncio.sleep(check_interval)
104104
return await self.get_result(with_logs=with_logs)

‎tests/test_task_timeout.py‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from types import ModuleType
2+
from unittest.mock import AsyncMock, Mock
3+
4+
import pytest
5+
6+
import taskiq.funcs as funcs_module
7+
import taskiq.task as task_module
8+
from taskiq.abc.result_backend import AsyncResultBackend
9+
from taskiq.exceptions import TaskiqResultTimeoutError
10+
from taskiq.result import TaskiqResult
11+
from taskiq.task import AsyncTaskiqTask
12+
13+
14+
async def wait_for_result(
15+
task: AsyncTaskiqTask[int],
16+
module: ModuleType,
17+
timeout: float,
18+
) -> TaskiqResult[int]:
19+
"""Exercise either public result-waiting API."""
20+
if module is funcs_module:
21+
return (await funcs_module.gather(task, timeout=timeout, periodicity=0))[0]
22+
return await task.wait_result(timeout=timeout, check_interval=0)
23+
24+
25+
@pytest.mark.parametrize("module", [task_module, funcs_module])
26+
async def test_result_timeout_survives_backward_wall_clock(
27+
monkeypatch: pytest.MonkeyPatch,
28+
module: ModuleType,
29+
) -> None:
30+
"""A backwards wall-clock jump must not postpone the timeout."""
31+
# Keep the old clock patched so this also reproduces on the unfixed source.
32+
monkeypatch.setattr(
33+
module,
34+
"time",
35+
Mock(side_effect=[100, 99, 98, 97]),
36+
raising=False,
37+
)
38+
monkeypatch.setattr(
39+
module,
40+
"monotonic",
41+
Mock(side_effect=[0, 0.5, 1.1]),
42+
raising=False,
43+
)
44+
backend = AsyncMock(spec=AsyncResultBackend)
45+
backend.is_result_ready.side_effect = [False, False, True]
46+
backend.get_result.return_value = TaskiqResult(
47+
is_err=False,
48+
return_value=42,
49+
execution_time=0,
50+
)
51+
task = AsyncTaskiqTask[int]("task", backend)
52+
53+
with pytest.raises(TaskiqResultTimeoutError):
54+
await wait_for_result(task, module, timeout=1)
55+
56+
assert backend.is_result_ready.await_count == (1 if module is funcs_module else 2)
57+
backend.get_result.assert_not_awaited()
58+
59+
60+
@pytest.mark.parametrize("module", [task_module, funcs_module])
61+
async def test_forward_wall_clock_does_not_expire_result_wait(
62+
monkeypatch: pytest.MonkeyPatch,
63+
module: ModuleType,
64+
) -> None:
65+
"""A forward wall-clock jump must not end an otherwise timely wait."""
66+
monkeypatch.setattr(module, "time", Mock(side_effect=[100, 10000]), raising=False)
67+
monkeypatch.setattr(
68+
module,
69+
"monotonic",
70+
Mock(side_effect=[0, 0.25, 0.5]),
71+
raising=False,
72+
)
73+
backend = AsyncMock(spec=AsyncResultBackend)
74+
backend.is_result_ready.side_effect = [False, True]
75+
result = TaskiqResult(is_err=False, return_value=42, execution_time=0)
76+
backend.get_result.return_value = result
77+
task = AsyncTaskiqTask[int]("task", backend)
78+
79+
assert await wait_for_result(task, module, timeout=1) is result
80+
81+
82+
@pytest.mark.parametrize("module", [task_module, funcs_module])
83+
@pytest.mark.parametrize("timeout", [-1, 0])
84+
async def test_result_wait_keeps_unlimited_timeout(
85+
monkeypatch: pytest.MonkeyPatch,
86+
module: ModuleType,
87+
timeout: float,
88+
) -> None:
89+
"""Nonpositive timeouts keep polling until the result is available."""
90+
monkeypatch.setattr(
91+
module,
92+
"time",
93+
Mock(side_effect=[100, 99, 98, 97]),
94+
raising=False,
95+
)
96+
monkeypatch.setattr(
97+
module,
98+
"monotonic",
99+
Mock(side_effect=[0, 100, 200]),
100+
raising=False,
101+
)
102+
backend = AsyncMock(spec=AsyncResultBackend)
103+
backend.is_result_ready.side_effect = [False, False, True]
104+
result = TaskiqResult(is_err=False, return_value=42, execution_time=0)
105+
backend.get_result.return_value = result
106+
task = AsyncTaskiqTask[int]("task", backend)
107+
108+
assert await wait_for_result(task, module, timeout=timeout) is result
109+
assert backend.is_result_ready.await_count == 3
110+
111+
112+
@pytest.mark.parametrize("module", [task_module, funcs_module])
113+
async def test_result_wait_keeps_strict_timeout_boundary(
114+
monkeypatch: pytest.MonkeyPatch,
115+
module: ModuleType,
116+
) -> None:
117+
"""Elapsed time equal to the timeout does not expire the wait."""
118+
monkeypatch.setattr(module, "time", Mock(side_effect=[0, 1]), raising=False)
119+
monkeypatch.setattr(module, "monotonic", Mock(side_effect=[0, 1]), raising=False)
120+
backend = AsyncMock(spec=AsyncResultBackend)
121+
backend.is_result_ready.side_effect = (
122+
[False, True] if module is task_module else [True]
123+
)
124+
result = TaskiqResult(is_err=False, return_value=42, execution_time=0)
125+
backend.get_result.return_value = result
126+
task = AsyncTaskiqTask[int]("task", backend)
127+
128+
assert await wait_for_result(task, module, timeout=1) is result

0 commit comments

Comments
 (0)