Skip to content

Commit 3c89db1

Browse files
committed
fix(scheduler): drain owned tasks before shutdown
1 parent c4b8666 commit 3c89db1

2 files changed

Lines changed: 194 additions & 34 deletions

File tree

‎taskiq/cli/scheduler/run.py‎

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ def __init__(
196196
self._update_schedules_task_future: asyncio.Task[Any] | None = None
197197

198198
def _update_schedules_task_future_callback(self, task_: asyncio.Task[Any]) -> None:
199+
if task_.cancelled():
200+
return
199201
self.scheduled_tasks = task_.result()
200202

201203
new_schedules_ids: set[ScheduleId] = set()
@@ -229,6 +231,18 @@ async def _update_scheduled_tasks(self) -> None:
229231
self._update_schedules_task_future_callback,
230232
)
231233

234+
async def _cancel_pending_tasks(
235+
self,
236+
running_schedules: dict[ScheduleId, asyncio.Task[Any]],
237+
) -> None:
238+
# Stop owned operations before callers close the broker and sources.
239+
pending_tasks = list(running_schedules.values())
240+
if self._update_schedules_task_future is not None:
241+
pending_tasks.append(self._update_schedules_task_future)
242+
for pending_task in pending_tasks:
243+
pending_task.cancel()
244+
await asyncio.gather(*pending_tasks, return_exceptions=True)
245+
232246
def _mark_cron_tasks_as_already_run(self) -> None:
233247
current_minute = datetime.now(tz=timezone.utc).replace(second=0, microsecond=0)
234248
for _, task_list in self.scheduled_tasks:
@@ -314,42 +328,48 @@ async def run(
314328

315329
await _sleep_until_next_second()
316330

317-
while True:
318-
now = datetime.now(tz=timezone.utc)
319-
next_run = (now + loop_interval).replace(microsecond=0)
320-
321-
if now - self.scheduled_tasks_updated_at >= update_interval:
322-
await self._update_scheduled_tasks()
323-
self.scheduled_tasks_updated_at = now
324-
325-
for source, task_list in self.scheduled_tasks:
326-
for task in task_list:
327-
is_ready_to_send: bool = self._is_schedule_ready_to_send(
328-
task=task,
329-
now=now,
330-
)
331-
332-
if is_ready_to_send and task.schedule_id not in running_schedules:
333-
send_task = self._event_loop.create_task(
334-
send(self.scheduler, source, task),
335-
# We need to set the name of the task
336-
# to be able to discard its reference
337-
# after it is done.
338-
name=f"schedule_{task.schedule_id}",
339-
)
340-
running_schedules[task.schedule_id] = send_task
341-
send_task.add_done_callback(
342-
lambda task_future: running_schedules.pop(
343-
task_future.get_name().removeprefix("schedule_"),
344-
),
331+
try:
332+
while True:
333+
now = datetime.now(tz=timezone.utc)
334+
next_run = (now + loop_interval).replace(microsecond=0)
335+
336+
if now - self.scheduled_tasks_updated_at >= update_interval:
337+
await self._update_scheduled_tasks()
338+
self.scheduled_tasks_updated_at = now
339+
340+
for source, task_list in self.scheduled_tasks:
341+
for task in task_list:
342+
is_ready_to_send: bool = self._is_schedule_ready_to_send(
343+
task=task,
344+
now=now,
345345
)
346346

347-
delay = next_run - datetime.now(tz=timezone.utc)
348-
logger.debug(
349-
"Sleeping for %.3f seconds before getting schedules.",
350-
delay.total_seconds(),
351-
)
352-
await asyncio.sleep(delay.total_seconds())
347+
if (
348+
is_ready_to_send
349+
and task.schedule_id not in running_schedules
350+
):
351+
send_task = self._event_loop.create_task(
352+
send(self.scheduler, source, task),
353+
# We need to set the name of the task
354+
# to be able to discard its reference
355+
# after it is done.
356+
name=f"schedule_{task.schedule_id}",
357+
)
358+
running_schedules[task.schedule_id] = send_task
359+
send_task.add_done_callback(
360+
lambda task_future: running_schedules.pop(
361+
task_future.get_name().removeprefix("schedule_"),
362+
),
363+
)
364+
365+
delay = next_run - datetime.now(tz=timezone.utc)
366+
logger.debug(
367+
"Sleeping for %.3f seconds before getting schedules.",
368+
delay.total_seconds(),
369+
)
370+
await asyncio.sleep(delay.total_seconds())
371+
finally:
372+
await self._cancel_pending_tasks(running_schedules)
353373

354374

355375
async def run_scheduler(args: SchedulerArgs) -> None:
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import asyncio
2+
from contextlib import suppress
3+
from datetime import timedelta
4+
from typing import Any
5+
from unittest.mock import AsyncMock
6+
7+
import pytest
8+
9+
from taskiq import BrokerMessage, ScheduleSource, TaskiqScheduler
10+
from taskiq.api import run_scheduler_task
11+
from taskiq.cli.scheduler.args import SchedulerArgs
12+
from taskiq.cli.scheduler.run import SchedulerLoop, run_scheduler
13+
from taskiq.scheduler.scheduled_task import ScheduledTask
14+
from tests.utils import AsyncQueueBroker
15+
16+
17+
class PendingOperation:
18+
def __init__(self, cleanup_allowed: asyncio.Event) -> None:
19+
self.started = asyncio.Event()
20+
self.cleanup_started = asyncio.Event()
21+
self.finished = asyncio.Event()
22+
self.cleanup_allowed = cleanup_allowed
23+
self.task: asyncio.Task[Any] | None = None
24+
25+
async def run(self) -> None:
26+
self.task = asyncio.current_task()
27+
self.started.set()
28+
try:
29+
await asyncio.Event().wait()
30+
finally:
31+
self.cleanup_started.set()
32+
await self.cleanup_allowed.wait()
33+
self.finished.set()
34+
35+
36+
@pytest.mark.parametrize("entrypoint", ["loop", "api", "cli"])
37+
async def test_shutdown_drains_sends_and_updates(
38+
entrypoint: str,
39+
monkeypatch: pytest.MonkeyPatch,
40+
caplog: pytest.LogCaptureFixture,
41+
) -> None:
42+
"""Owned operations must finish cleanup before scheduler resources close."""
43+
cleanup_allowed = asyncio.Event()
44+
sending = PendingOperation(cleanup_allowed)
45+
updating = PendingOperation(cleanup_allowed)
46+
shutdowns: list[str] = []
47+
48+
class BlockingBroker(AsyncQueueBroker):
49+
async def kick(self, message: BrokerMessage) -> None:
50+
await sending.run()
51+
52+
async def shutdown(self) -> None:
53+
shutdowns.append("broker")
54+
assert sending.finished.is_set()
55+
assert updating.finished.is_set()
56+
await super().shutdown()
57+
58+
class BlockingSource(ScheduleSource):
59+
fetched = False
60+
61+
async def get_schedules(self) -> list[ScheduledTask]:
62+
if self.fetched:
63+
await updating.run()
64+
self.fetched = True
65+
return [
66+
ScheduledTask(
67+
task_name="test",
68+
labels={},
69+
args=[],
70+
kwargs={},
71+
interval=1,
72+
),
73+
]
74+
75+
async def shutdown(self) -> None:
76+
shutdowns.append("source")
77+
assert updating.finished.is_set()
78+
79+
monkeypatch.setattr(
80+
"taskiq.cli.scheduler.run._sleep_until_next_second",
81+
AsyncMock(),
82+
)
83+
scheduler = TaskiqScheduler(BlockingBroker(), [BlockingSource()])
84+
if entrypoint == "cli":
85+
coroutine = run_scheduler(
86+
SchedulerArgs(
87+
scheduler=scheduler,
88+
modules=[],
89+
update_interval=0,
90+
configure_logging=False,
91+
),
92+
)
93+
elif entrypoint == "api":
94+
coroutine = run_scheduler_task(scheduler, interval=timedelta(0))
95+
else:
96+
coroutine = SchedulerLoop(scheduler).run(update_interval=timedelta(0))
97+
98+
scheduler_task = asyncio.create_task(coroutine)
99+
cleanup_started = asyncio.gather(
100+
sending.cleanup_started.wait(),
101+
updating.cleanup_started.wait(),
102+
)
103+
try:
104+
await asyncio.wait_for(
105+
asyncio.gather(sending.started.wait(), updating.started.wait()),
106+
5,
107+
)
108+
scheduler_task.cancel()
109+
waitables: list[asyncio.Future[Any]] = [scheduler_task, cleanup_started]
110+
done, _ = await asyncio.wait(
111+
waitables,
112+
timeout=5,
113+
return_when=asyncio.FIRST_COMPLETED,
114+
)
115+
assert cleanup_started in done
116+
assert not scheduler_task.done()
117+
assert not shutdowns
118+
119+
cleanup_allowed.set()
120+
with suppress(asyncio.CancelledError):
121+
await asyncio.wait_for(scheduler_task, 5)
122+
123+
assert sending.finished.is_set()
124+
assert updating.finished.is_set()
125+
assert scheduler_task.cancelled() == (entrypoint != "cli")
126+
assert shutdowns == (["broker", "source"] if entrypoint == "cli" else [])
127+
finally:
128+
cleanup_allowed.set()
129+
tasks = [
130+
task
131+
for task in (scheduler_task, sending.task, updating.task)
132+
if task is not None
133+
]
134+
for task in tasks:
135+
task.cancel()
136+
await asyncio.gather(*tasks, return_exceptions=True)
137+
cleanup_started.cancel()
138+
await asyncio.gather(cleanup_started, return_exceptions=True)
139+
140+
assert "Exception in callback" not in caplog.text

0 commit comments

Comments
 (0)