Skip to content

Commit 0a65cde

Browse files
fix(scheduler): clean up started sources when startup fails (#672)
1 parent 9464f6a commit 0a65cde

2 files changed

Lines changed: 216 additions & 5 deletions

File tree

‎taskiq/cli/scheduler/run.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,29 @@ async def run( # noqa: C901
408408
await asyncio.sleep(delay.total_seconds())
409409

410410

411+
async def _startup_scheduler(scheduler: TaskiqScheduler) -> None:
412+
"""Start sources and scheduler, rolling back sources if startup fails."""
413+
started_sources: list[ScheduleSource] = []
414+
try:
415+
for source in scheduler.sources:
416+
await source.startup()
417+
started_sources.append(source)
418+
419+
logger.info("Starting scheduler.")
420+
await scheduler.startup()
421+
except (Exception, asyncio.CancelledError):
422+
# Only completed startups have a matching shutdown contract.
423+
for source in reversed(started_sources):
424+
try:
425+
await source.shutdown()
426+
except (Exception, asyncio.CancelledError):
427+
logger.exception(
428+
"Cannot shut down source after startup failure: %s",
429+
source,
430+
)
431+
raise
432+
433+
411434
async def run_scheduler(args: SchedulerArgs) -> None:
412435
"""
413436
Run scheduler.
@@ -442,9 +465,6 @@ async def run_scheduler(args: SchedulerArgs) -> None:
442465

443466
scheduler.broker.is_scheduler_process = True
444467
import_tasks(args.modules, args.tasks_pattern, args.fs_discover)
445-
for source in scheduler.sources:
446-
await source.startup()
447-
448468
update_interval = timedelta(seconds=60)
449469
if args.update_interval is not None:
450470
update_interval = timedelta(seconds=args.update_interval)
@@ -453,8 +473,7 @@ async def run_scheduler(args: SchedulerArgs) -> None:
453473
if args.loop_interval is not None:
454474
loop_interval = timedelta(seconds=args.loop_interval)
455475

456-
logger.info("Starting scheduler.")
457-
await scheduler.startup()
476+
await _startup_scheduler(scheduler)
458477
logger.info("Startup completed.")
459478

460479
scheduler_loop = SchedulerLoop(scheduler)

‎tests/cli/scheduler/test_run.py‎

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import asyncio
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
5+
6+
from taskiq import ScheduleSource, TaskiqScheduler
7+
from taskiq.abc.broker import AsyncBroker
8+
from taskiq.cli.scheduler.args import SchedulerArgs
9+
from taskiq.cli.scheduler.run import SchedulerLoop, run_scheduler
10+
from taskiq.scheduler.scheduled_task import ScheduledTask
11+
12+
13+
class RecordingSource(ScheduleSource):
14+
def __init__(
15+
self,
16+
name: str,
17+
events: list[str],
18+
startup_error: BaseException | None = None,
19+
shutdown_error: BaseException | None = None,
20+
) -> None:
21+
self.name = name
22+
self.events = events
23+
self.startup_error = startup_error
24+
self.shutdown_error = shutdown_error
25+
26+
async def startup(self) -> None:
27+
self.events.append(f"start {self.name}")
28+
if self.startup_error is not None:
29+
raise self.startup_error
30+
31+
async def shutdown(self) -> None:
32+
await asyncio.sleep(0)
33+
self.events.append(f"stop {self.name}")
34+
if self.shutdown_error is not None:
35+
raise self.shutdown_error
36+
37+
async def get_schedules(self) -> list[ScheduledTask]:
38+
return []
39+
40+
41+
@pytest.fixture
42+
def broker() -> AsyncMock:
43+
return AsyncMock(spec=AsyncBroker)
44+
45+
46+
@pytest.fixture
47+
def loop_run(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
48+
run = AsyncMock(side_effect=asyncio.CancelledError)
49+
monkeypatch.setattr(SchedulerLoop, "run", run)
50+
return run
51+
52+
53+
@pytest.mark.parametrize("failed_index", [0, 1, 2])
54+
async def test_source_startup_failure(
55+
broker: AsyncMock,
56+
loop_run: AsyncMock,
57+
failed_index: int,
58+
) -> None:
59+
events: list[str] = []
60+
error = RuntimeError("source unavailable")
61+
sources = [RecordingSource(str(index), events) for index in range(3)]
62+
sources[failed_index].startup_error = error
63+
scheduler = TaskiqScheduler(broker, sources=list(sources))
64+
65+
with pytest.raises(RuntimeError) as exc_info:
66+
await run_scheduler(SchedulerArgs(scheduler=scheduler, modules=[]))
67+
68+
assert exc_info.value is error
69+
assert events == [f"start {index}" for index in range(failed_index + 1)] + [
70+
f"stop {index}" for index in reversed(range(failed_index))
71+
]
72+
broker.startup.assert_not_awaited()
73+
broker.shutdown.assert_not_awaited()
74+
loop_run.assert_not_awaited()
75+
76+
77+
@pytest.mark.parametrize("error_type", [RuntimeError, asyncio.CancelledError])
78+
async def test_scheduler_startup_failure(
79+
broker: AsyncMock,
80+
loop_run: AsyncMock,
81+
error_type: type[BaseException],
82+
) -> None:
83+
events: list[str] = []
84+
error = error_type("broker unavailable")
85+
broker.startup.side_effect = error
86+
scheduler = TaskiqScheduler(
87+
broker,
88+
sources=[RecordingSource("a", events), RecordingSource("b", events)],
89+
)
90+
91+
with pytest.raises(error_type) as exc_info:
92+
await run_scheduler(SchedulerArgs(scheduler=scheduler, modules=[]))
93+
94+
assert exc_info.value is error
95+
assert events == ["start a", "start b", "stop b", "stop a"]
96+
broker.startup.assert_awaited_once()
97+
broker.shutdown.assert_not_awaited()
98+
loop_run.assert_not_awaited()
99+
100+
101+
@pytest.mark.parametrize("cleanup_error_type", [RuntimeError, asyncio.CancelledError])
102+
async def test_cleanup_failure_preserves_startup_error(
103+
broker: AsyncMock,
104+
loop_run: AsyncMock,
105+
caplog: pytest.LogCaptureFixture,
106+
cleanup_error_type: type[BaseException],
107+
) -> None:
108+
events: list[str] = []
109+
error = RuntimeError("source unavailable")
110+
cleanup_error = cleanup_error_type("cleanup failed")
111+
scheduler = TaskiqScheduler(
112+
broker,
113+
sources=[
114+
RecordingSource("a", events),
115+
RecordingSource("b", events, shutdown_error=cleanup_error),
116+
RecordingSource("c", events, startup_error=error),
117+
],
118+
)
119+
120+
with pytest.raises(RuntimeError) as exc_info:
121+
await run_scheduler(SchedulerArgs(scheduler=scheduler, modules=[]))
122+
123+
assert exc_info.value is error
124+
assert events == ["start a", "start b", "start c", "stop b", "stop a"]
125+
assert any(
126+
record.exc_info is not None and record.exc_info[1] is cleanup_error
127+
for record in caplog.records
128+
)
129+
broker.shutdown.assert_not_awaited()
130+
loop_run.assert_not_awaited()
131+
132+
133+
async def test_cancel_during_source_startup(
134+
broker: AsyncMock,
135+
loop_run: AsyncMock,
136+
monkeypatch: pytest.MonkeyPatch,
137+
) -> None:
138+
events: list[str] = []
139+
starting = asyncio.Event()
140+
source = RecordingSource("b", events)
141+
142+
async def blocked_startup() -> None:
143+
events.append("start b")
144+
starting.set()
145+
await asyncio.Future[None]()
146+
147+
monkeypatch.setattr(source, "startup", blocked_startup)
148+
scheduler = TaskiqScheduler(
149+
broker,
150+
sources=[RecordingSource("a", events), source, RecordingSource("c", events)],
151+
)
152+
task = asyncio.create_task(
153+
run_scheduler(SchedulerArgs(scheduler=scheduler, modules=[])),
154+
)
155+
try:
156+
await asyncio.wait_for(starting.wait(), timeout=1)
157+
task.cancel()
158+
with pytest.raises(asyncio.CancelledError):
159+
await asyncio.wait_for(task, timeout=1)
160+
finally:
161+
task.cancel()
162+
await asyncio.gather(task, return_exceptions=True)
163+
164+
assert task.cancelled()
165+
assert events == ["start a", "start b", "stop a"]
166+
broker.startup.assert_not_awaited()
167+
broker.shutdown.assert_not_awaited()
168+
loop_run.assert_not_awaited()
169+
170+
171+
async def test_normal_shutdown(broker: AsyncMock, loop_run: AsyncMock) -> None:
172+
events: list[str] = []
173+
broker.startup.side_effect = lambda: events.append("start broker")
174+
broker.shutdown.side_effect = lambda: events.append("stop broker")
175+
scheduler = TaskiqScheduler(
176+
broker,
177+
sources=[RecordingSource("a", events), RecordingSource("b", events)],
178+
)
179+
180+
await run_scheduler(SchedulerArgs(scheduler=scheduler, modules=[]))
181+
182+
assert events == [
183+
"start a",
184+
"start b",
185+
"start broker",
186+
"stop broker",
187+
"stop a",
188+
"stop b",
189+
]
190+
broker.startup.assert_awaited_once()
191+
broker.shutdown.assert_awaited_once()
192+
loop_run.assert_awaited_once()

0 commit comments

Comments
 (0)