Skip to content

Commit 60e1903

Browse files
committed
fix(inmemory): harden accepted task lifecycle
Preserve accepted work across hook failures and cancellation. Drain broker-owned tasks before closing lifecycle resources. Move executor shutdown off the event loop and retain first failures. Add deterministic ordering, concurrency, and shutdown coverage. Refs: #586
1 parent c41a763 commit 60e1903

9 files changed

Lines changed: 1421 additions & 143 deletions

File tree

‎docs/available-components/brokers.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,20 @@ In this section we'll list officially supported brokers.
88

99
## InMemoryBroker
1010

11-
This is a special broker for local development. It uses the same functions to execute tasks,
12-
but all tasks are executed locally in the current thread.
11+
This is a special broker for local development. It uses the same functions to
12+
execute tasks in the current process. Async task functions run on the event
13+
loop, while sync task functions use the broker's thread pool.
1314
By default it uses `InMemoryResultBackend` but this can be overridden.
1415

1516
`startup()` and `shutdown()` run both the client and worker event phases because
1617
the broker performs both roles in one process. They also own middleware and
17-
result backend lifecycle. Shutdown waits for local background executions before
18-
closing those resources and the synchronous task executor.
18+
result backend lifecycle. Shutdown stops accepting new local executions, waits
19+
for already accepted work (including active send middleware), then closes those
20+
resources and the synchronous task executor. A send started after shutdown is
21+
rejected before its `pre_send` hooks run.
22+
Once cleanup completes, later `shutdown()` calls do not close those resources
23+
again. If cancellation interrupts a lifecycle hook, it propagates immediately
24+
and a later call resumes cleanup from that hook.
1925

2026
## ZeroMQBroker
2127

‎docs/guide/testing-taskiq.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ broker = InMemoryBroker(await_inplace=True)
165165

166166
With this setup all `await function.kiq()` calls will behave similarly to `await function()`, but
167167
with dependency injection and all taskiq-related functionality.
168+
For async task functions, successful inline execution remains in the caller's
169+
asyncio task and context. Sync task functions still run in the broker's thread
170+
pool with a copy of the current `ContextVars`.
168171
The normal middleware boundary is preserved in both execution modes: all
169172
`post_send` hooks finish before `pre_execute` and the task body begin.
170173
With `await_inplace=True`, the `kiq()` call also returns only after inline
@@ -191,9 +194,16 @@ if that callback completed before `wait_all()` was called. The failure is
191194
consumed after it is raised, so a later `wait_all()` only observes newer work.
192195
Cancelling a `wait_all()` call cancels only that waiter; accepted executions
193196
remain tracked and can be drained by a later call.
194-
Calling `shutdown()` performs the same drain before closing middleware, result
195-
backend, and executor resources. If shutdown is cancelled while draining, it
196-
finishes the drain and resource cleanup before propagating the cancellation.
197+
Calling `shutdown()` rejects new sends, performs the same drain, then closes
198+
middleware, result backend, and executor resources. If shutdown is cancelled
199+
while draining accepted work, it finishes that drain and cleanup before
200+
propagating the cancellation.
201+
Cancellation from a shutdown event, middleware, or result backend hook instead
202+
propagates immediately. A later `shutdown()` resumes at the interrupted hook
203+
without repeating completed cleanup, while calls after completed cleanup are
204+
no-ops.
205+
An invocation already running `pre_send` is part of that drain; a later
206+
invocation is rejected before `pre_send` can produce side effects.
197207
Both drain methods must be called by the external test or application lifecycle
198208
owner. Calling `wait_all()` or `shutdown()` from a task or `post_send` hook
199209
managed by the same broker raises `RuntimeError` instead of waiting on itself.

‎taskiq/abc/broker.py‎

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import warnings
44
from abc import ABC, abstractmethod
55
from collections import defaultdict
6-
from collections.abc import AsyncGenerator, Awaitable, Callable
7-
from functools import wraps
6+
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
7+
from contextlib import contextmanager
8+
from functools import partial, wraps
89
from logging import getLogger
910
from typing import (
1011
TYPE_CHECKING,
@@ -47,6 +48,7 @@
4748
_ReturnType = TypeVar("_ReturnType")
4849

4950
EventHandler: TypeAlias = Callable[[TaskiqState], Awaitable[None] | None]
51+
ShutdownHook: TypeAlias = Callable[[], Awaitable[None] | None]
5052

5153
logger = getLogger("taskiq")
5254

@@ -118,6 +120,7 @@ def __init__(
118120
self.is_worker_process = False
119121
# True only if broker runs in scheduler process.
120122
self.is_scheduler_process = False
123+
self._shutdown_resource_index = 0
121124

122125
def find_task(self, task_name: str) -> AsyncTaskiqDecoratedTask[Any, Any] | None:
123126
"""
@@ -209,35 +212,37 @@ async def shutdown(self) -> None:
209212
This method is called,
210213
when broker is closing.
211214
"""
212-
shutdown_error: BaseException | None = None
215+
shutdown_errors: list[BaseException] = []
216+
await self._shutdown_resources(shutdown_errors)
213217

218+
if shutdown_errors:
219+
raise shutdown_errors[0]
220+
221+
async def _shutdown_resources(
222+
self,
223+
shutdown_errors: list[BaseException],
224+
) -> None:
225+
"""Close every registered resource and record failures in order."""
226+
for index, shutdown_hook in enumerate(self._iter_shutdown_hooks()):
227+
if index < self._shutdown_resource_index:
228+
continue
229+
try:
230+
await maybe_awaitable(shutdown_hook())
231+
except Exception as exc:
232+
self._record_shutdown_error(shutdown_errors, exc)
233+
self._shutdown_resource_index = index + 1
234+
235+
def _iter_shutdown_hooks(self) -> Iterator[ShutdownHook]:
236+
"""Yield lifecycle hooks in their shutdown order."""
214237
for event in self._get_shutdown_events():
215238
for handler in self.event_handlers[event]:
216-
try:
217-
await maybe_awaitable(handler(self.state))
218-
except BaseException as exc:
219-
shutdown_error = self._remember_shutdown_error(
220-
shutdown_error,
221-
exc,
222-
)
239+
yield partial(handler, self.state)
223240

224241
for middleware in self.middlewares:
225242
if middleware.__class__.shutdown != TaskiqMiddleware.shutdown:
226-
try:
227-
await maybe_awaitable(middleware.shutdown())
228-
except BaseException as exc:
229-
shutdown_error = self._remember_shutdown_error(
230-
shutdown_error,
231-
exc,
232-
)
233-
234-
try:
235-
await self.result_backend.shutdown()
236-
except BaseException as exc:
237-
shutdown_error = self._remember_shutdown_error(shutdown_error, exc)
243+
yield middleware.shutdown
238244

239-
if shutdown_error is not None:
240-
raise shutdown_error
245+
yield self.result_backend.shutdown
241246

242247
def _get_shutdown_events(self) -> tuple[TaskiqEvents, ...]:
243248
"""Return event phases owned by this broker shutdown."""
@@ -259,6 +264,23 @@ def _remember_shutdown_error(
259264
)
260265
return first_error
261266

267+
@classmethod
268+
def _record_shutdown_error(
269+
cls,
270+
shutdown_errors: list[BaseException],
271+
current_error: BaseException,
272+
) -> None:
273+
"""Record the first failure when it occurs and log later failures."""
274+
first_error = shutdown_errors[0] if shutdown_errors else None
275+
remembered_error = cls._remember_shutdown_error(first_error, current_error)
276+
if first_error is None:
277+
shutdown_errors.append(remembered_error)
278+
279+
@contextmanager
280+
def _send_lifecycle(self) -> Iterator[None]:
281+
"""Own package-internal client send work through broker handoff."""
282+
yield
283+
262284
async def _kick_with_post_send(
263285
self,
264286
message: BrokerMessage,

0 commit comments

Comments
 (0)