Skip to content

Commit a7eb7a5

Browse files
committed
fix(routing): harden retry and lifecycle ownership
- keep immediate retries on the receiving broker - protect callback cleanup from repeated cancellation - make scheduler rollback cleanup idempotent - document unsupported-flow requeue behavior
1 parent 4a94eda commit a7eb7a5

9 files changed

Lines changed: 255 additions & 28 deletions

File tree

docs/guide/routing-and-flows.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ contract:
341341
- A failed or cancelled publication remains unacknowledged for
342342
`when_executed`, `when_saved` and `manual`, allowing broker redelivery. The
343343
original label is not incremented.
344+
- `UnsupportedFlowError` follows the same failure contract. A legacy adapter
345+
with an explicit same-broker Flow can therefore redeliver the original until
346+
the route is corrected or the transport's dead-letter policy intervenes.
344347
- `when_received` is intentionally different: the original was acknowledged
345348
before task execution, so a later publication failure cannot restore it.
346349
- If acknowledgement itself fails after successful publication, that error is
@@ -519,6 +522,12 @@ Scheduler and requeue compatibility:
519522
Configure broker membership before scheduler startup.
520523
- `Context.requeue()` sends through the broker currently executing the task. It
521524
does not re-resolve a task route across brokers.
525+
- Immediate retries published by `SimpleRetryMiddleware` or
526+
`SmartRetryMiddleware` stay on the broker currently executing the task, so
527+
the retry keeps that broker's send lifecycle and result backend. When
528+
`SmartRetryMiddleware` uses a `ScheduleSource`, it creates a normal
529+
`ScheduledTask`; the scheduler then applies the late route-resolution rules
530+
above.
522531
- Taskiq does not preserve inbound source-flow provenance yet because that data
523532
is not stored in `TaskiqMessage` or `Context`.
524533

taskiq/middlewares/simple_retry_middleware.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,15 @@ async def on_error(
6565
if not retry_on_error:
6666
return
6767

68-
kicker: AsyncKicker[Any, Any] = AsyncKicker(
69-
task_name=message.task_name,
70-
broker=self.broker,
71-
labels=message.labels,
72-
).with_task_id(message.task_id)
68+
kicker: AsyncKicker[Any, Any] = (
69+
AsyncKicker(
70+
task_name=message.task_name,
71+
broker=self.broker,
72+
labels=message.labels,
73+
)
74+
.with_broker(self.broker)
75+
.with_task_id(message.task_id)
76+
)
7377

7478
# Getting number of previous retries.
7579
retries = int(message.labels.get("_retries", 0)) + 1

taskiq/middlewares/smart_retry_middleware.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ async def on_error(
176176
broker=self.broker,
177177
labels=message.labels,
178178
)
179+
.with_broker(self.broker)
179180
.with_task_id(message.task_id)
180181
.with_labels(_retries=retries)
181182
)

taskiq/receiver/receiver.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -640,7 +640,7 @@ async def runner(
640640
641641
:param queue: queue with prefetched data.
642642
"""
643-
cancelled = False
643+
cancellation: asyncio.CancelledError | None = None
644644
try:
645645
while True:
646646
queued_message = await queue.get()
@@ -650,13 +650,25 @@ async def runner(
650650
raise TypeError("Receiver queue contains an invalid message.")
651651
await self._start_callback(queued_message)
652652

653-
except asyncio.CancelledError:
654-
cancelled = True
655-
raise
653+
except asyncio.CancelledError as exc:
654+
cancellation = exc
656655
finally:
657-
await self._drain_active_tasks(cancel_immediately=cancelled)
656+
with anyio.CancelScope(shield=True):
657+
cleanup_cancellation = await self._drain_active_tasks(
658+
cancel_immediately=cancellation is not None,
659+
)
660+
if cancellation is None:
661+
cancellation = cleanup_cancellation
662+
663+
if cancellation is not None:
664+
raise cancellation
658665
logger.info("The runner is stopped.")
659666

667+
async def _run_owned_callback(self, message: bytes | AckableMessage) -> None:
668+
"""Run a callback outside repeated listener-scope cancellation."""
669+
with anyio.CancelScope(shield=True):
670+
await self.callback(message=message, raise_err=False)
671+
660672
async def _start_callback(self, message: _PrefetchedMessage) -> None:
661673
"""Transfer one prefetched delivery into an owned callback task."""
662674
owns_prefetch_slot = message.owns_prefetch_slot
@@ -671,7 +683,7 @@ async def _start_callback(self, message: _PrefetchedMessage) -> None:
671683
self.sem_prefetch.release()
672684
owns_prefetch_slot = False
673685
task = asyncio.create_task(
674-
self.callback(message=message.data, raise_err=False),
686+
self._run_owned_callback(message.data),
675687
)
676688
except BaseException:
677689
if owns_prefetch_slot:
@@ -704,11 +716,15 @@ def _on_callback_done(self, task: "asyncio.Task[Any]") -> None:
704716
),
705717
)
706718

707-
async def _drain_active_tasks(self, *, cancel_immediately: bool) -> None:
719+
async def _drain_active_tasks(
720+
self,
721+
*,
722+
cancel_immediately: bool,
723+
) -> asyncio.CancelledError | None:
708724
"""Wait for owned callbacks and cancel them after the graceful boundary."""
709725
tasks = set(self._active_tasks)
710726
if not tasks:
711-
return
727+
return None
712728

713729
logger.info("Waiting for %d running tasks to complete...", len(tasks))
714730
cancellation: asyncio.CancelledError | None = None
@@ -733,25 +749,24 @@ async def _drain_active_tasks(self, *, cancel_immediately: bool) -> None:
733749
if cancellation is None:
734750
cancellation = cleanup_cancellation
735751
logger.info("No more tasks to wait for. Shutting down.")
736-
if cancellation is not None:
737-
raise cancellation
752+
return cancellation
738753

739754
@staticmethod
740755
async def _cancel_callback_tasks(
741756
tasks: set[asyncio.Task[Any]],
742757
) -> asyncio.CancelledError | None:
743-
"""Cancel callback tasks and await cleanup despite one outer cancellation."""
758+
"""Cancel callbacks once and await cleanup despite outer cancellation."""
744759
for task in tasks:
745760
task.cancel()
746-
try:
747-
await asyncio.gather(*tasks, return_exceptions=True)
748-
except asyncio.CancelledError as exc:
749-
for task in tasks:
750-
if not task.done():
751-
task.cancel()
752-
await asyncio.gather(*tasks, return_exceptions=True)
753-
return exc
754-
return None
761+
waiter = asyncio.gather(*tasks, return_exceptions=True)
762+
cancellation: asyncio.CancelledError | None = None
763+
while not waiter.done():
764+
try:
765+
await asyncio.shield(waiter)
766+
except asyncio.CancelledError as exc:
767+
if cancellation is None:
768+
cancellation = exc
769+
return cancellation
755770

756771
def _record_listen_error(self, error: BaseException) -> None:
757772
"""Preserve the first transport error while allowing iterator cleanup."""

taskiq/scheduler/scheduler.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from enum import Enum, auto
34
from logging import getLogger
45
from typing import TYPE_CHECKING, Any
56

@@ -16,6 +17,16 @@
1617
logger = getLogger(__name__)
1718

1819

20+
class _SchedulerLifecycle(Enum):
21+
"""Internal scheduler resource lifecycle."""
22+
23+
CREATED = auto()
24+
STARTING = auto()
25+
STARTED = auto()
26+
STOPPING = auto()
27+
STOPPED = auto()
28+
29+
1930
class TaskiqScheduler:
2031
"""Scheduler class."""
2132

@@ -27,6 +38,7 @@ def __init__(
2738
self.broker = broker
2839
self.sources = sources
2940
self._started_brokers: list[AsyncBroker] = []
41+
self._lifecycle = _SchedulerLifecycle.CREATED
3042

3143
async def startup(self) -> None:
3244
"""
@@ -37,9 +49,15 @@ async def startup(self) -> None:
3749
producer-side resources because late route resolution may select any of
3850
them.
3951
"""
40-
if self._started_brokers:
52+
if self._lifecycle in {
53+
_SchedulerLifecycle.STARTING,
54+
_SchedulerLifecycle.STARTED,
55+
}:
4156
raise RuntimeError("TaskiqScheduler is already started.")
57+
if self._lifecycle is _SchedulerLifecycle.STOPPING:
58+
raise RuntimeError("TaskiqScheduler is shutting down.")
4259

60+
self._lifecycle = _SchedulerLifecycle.STARTING
4361
try:
4462
for broker in self._managed_brokers():
4563
broker.is_scheduler_process = True
@@ -50,6 +68,7 @@ async def startup(self) -> None:
5068
tuple(self._started_brokers),
5169
)
5270
self._started_brokers.clear()
71+
self._lifecycle = _SchedulerLifecycle.STOPPED
5372
if cleanup_error is not None:
5473
logger.error(
5574
"Error while cleaning up partially started scheduler brokers.",
@@ -60,6 +79,7 @@ async def startup(self) -> None:
6079
),
6180
)
6281
raise
82+
self._lifecycle = _SchedulerLifecycle.STARTED
6383

6484
async def on_ready(self, source: ScheduleSource, task: ScheduledTask) -> None:
6585
"""
@@ -86,11 +106,22 @@ async def on_ready(self, source: ScheduleSource, task: ScheduledTask) -> None:
86106

87107
async def shutdown(self) -> None:
88108
"""Shut down scheduler brokers in reverse startup order."""
109+
if self._lifecycle is _SchedulerLifecycle.STOPPED:
110+
return
111+
if self._lifecycle is _SchedulerLifecycle.STARTING:
112+
raise RuntimeError("TaskiqScheduler is still starting.")
113+
if self._lifecycle is _SchedulerLifecycle.STOPPING:
114+
raise RuntimeError("TaskiqScheduler is already shutting down.")
115+
89116
brokers = tuple(self._started_brokers)
90117
if not brokers:
91118
brokers = (self.broker,)
92119
self._started_brokers.clear()
93-
shutdown_error = await self._shutdown_brokers(brokers)
120+
self._lifecycle = _SchedulerLifecycle.STOPPING
121+
try:
122+
shutdown_error = await self._shutdown_brokers(brokers)
123+
finally:
124+
self._lifecycle = _SchedulerLifecycle.STOPPED
94125
if shutdown_error is not None:
95126
raise shutdown_error
96127

tests/middlewares/test_simple_retry.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33

44
import pytest
55

6+
from taskiq import SmartRetryMiddleware, TaskiqRouter
67
from taskiq.formatters.json_formatter import JSONFormatter
78
from taskiq.message import TaskiqMessage
89
from taskiq.middlewares.simple_retry_middleware import SimpleRetryMiddleware
910
from taskiq.result import TaskiqResult
11+
from tests.utils import RecordingBroker
1012

1113

1214
@pytest.fixture
@@ -73,3 +75,49 @@ async def test_max_retries(broker: AsyncMock) -> None:
7375
Exception(),
7476
)
7577
broker.kick.assert_not_called()
78+
79+
80+
@pytest.mark.parametrize(
81+
"middleware",
82+
[
83+
pytest.param(SimpleRetryMiddleware(), id="simple"),
84+
pytest.param(SmartRetryMiddleware(default_delay=0), id="smart"),
85+
],
86+
)
87+
async def test_retry_keeps_current_broker_when_route_points_elsewhere(
88+
middleware: SimpleRetryMiddleware | SmartRetryMiddleware,
89+
) -> None:
90+
router = TaskiqRouter()
91+
current_broker = RecordingBroker(
92+
router=router,
93+
broker_name="current",
94+
)
95+
routed_broker = RecordingBroker(
96+
router=router,
97+
broker_name="routed",
98+
)
99+
current_broker.add_middlewares(middleware)
100+
router.route_task("meme", broker=routed_broker)
101+
result = TaskiqResult(is_err=True, return_value=None, execution_time=0.0)
102+
103+
await middleware.on_error(
104+
TaskiqMessage(
105+
task_id="test_id",
106+
task_name="meme",
107+
labels={
108+
"max_retries": 2,
109+
"retry_on_error": True,
110+
},
111+
args=[],
112+
kwargs={},
113+
),
114+
result,
115+
RuntimeError("retry"),
116+
)
117+
118+
assert routed_broker.sent == []
119+
assert len(current_broker.sent) == 1
120+
retried_message, retried_flow = current_broker.sent[0]
121+
assert retried_message.task_name == "meme"
122+
assert retried_message.labels["_retries"] == "1"
123+
assert retried_flow is None

tests/receiver/test_runtime.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,80 @@ async def blocked_task() -> None:
719719
assert cleaned_before_runtime_returned
720720

721721

722+
async def test_runtime_hard_cancel_awaits_multiphase_callback_cleanup(
723+
runtime_executor: ThreadPoolExecutor,
724+
caplog: pytest.LogCaptureFixture,
725+
) -> None:
726+
events: list[str] = []
727+
cleanup_started = asyncio.Event()
728+
cleanup_finished = asyncio.Event()
729+
cleanup_interrupted = asyncio.Event()
730+
release_cleanup = asyncio.Event()
731+
callback_started = asyncio.Event()
732+
733+
class CleanupOrderBroker(RuntimeBroker):
734+
async def shutdown(self) -> None:
735+
self.events.append("broker.shutdown.started")
736+
await super().shutdown()
737+
738+
broker = CleanupOrderBroker(
739+
router=TaskiqRouter(),
740+
broker_name="listener",
741+
events=events,
742+
)
743+
744+
@broker.task(task_name="runtime.multiphase_callback_cleanup")
745+
async def blocked_task() -> None:
746+
callback_started.set()
747+
try:
748+
await asyncio.Event().wait()
749+
finally:
750+
cleanup_started.set()
751+
try:
752+
await release_cleanup.wait()
753+
for _ in range(20):
754+
await asyncio.sleep(0)
755+
except asyncio.CancelledError:
756+
cleanup_interrupted.set()
757+
raise
758+
events.append("callback.cleanup.finished")
759+
cleanup_finished.set()
760+
761+
finish_event = asyncio.Event()
762+
runtime = build_runtime(
763+
(broker,),
764+
runtime_executor,
765+
options=runtime_options(
766+
wait_tasks_timeout=0.01,
767+
shutdown_timeout=0.05,
768+
),
769+
)
770+
caplog.set_level(logging.WARNING, logger="taskiq.worker.runtime")
771+
runtime_task = asyncio.create_task(runtime.run(finish_event))
772+
await broker.listen_started.wait()
773+
await blocked_task.kiq()
774+
await callback_started.wait()
775+
776+
finish_event.set()
777+
await cleanup_started.wait()
778+
779+
async def wait_for_hard_cancel() -> None:
780+
while not any(
781+
"did not stop" in record.getMessage() for record in caplog.records
782+
):
783+
await asyncio.sleep(0)
784+
785+
await asyncio.wait_for(wait_for_hard_cancel(), timeout=1)
786+
release_cleanup.set()
787+
await asyncio.wait_for(runtime_task, timeout=1)
788+
789+
assert cleanup_finished.is_set()
790+
assert not cleanup_interrupted.is_set()
791+
assert events.index("callback.cleanup.finished") < events.index(
792+
"broker.shutdown.started",
793+
)
794+
795+
722796
async def test_runtime_continues_cleanup_after_broker_shutdown_failure(
723797
runtime_executor: ThreadPoolExecutor,
724798
caplog: pytest.LogCaptureFixture,

0 commit comments

Comments
 (0)