Skip to content

Commit d89d186

Browse files
committed
fix(routing): harden lifecycle coverage and callable task binding
- cover receiver and worker runtime cancellation and shutdown paths - validate scheduler, router, registry, CLI, and broker lifecycle contracts - support callable-object shared tasks during broker binding - organize runtime fault tests into focused modules
1 parent a7eb7a5 commit d89d186

14 files changed

Lines changed: 1395 additions & 138 deletions

taskiq/abc/broker.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from abc import ABC, abstractmethod
55
from collections import defaultdict
66
from collections.abc import AsyncGenerator, Awaitable, Callable
7-
from functools import wraps
7+
from functools import WRAPPER_ASSIGNMENTS, update_wrapper
88
from logging import getLogger
99
from typing import (
1010
TYPE_CHECKING,
@@ -13,6 +13,7 @@
1313
ParamSpec,
1414
TypeAlias,
1515
TypeVar,
16+
cast,
1617
get_type_hints,
1718
overload,
1819
)
@@ -532,19 +533,29 @@ def _decorate_task(
532533
):
533534
raise TypeError("base_cls must be a subclass of AsyncTaskiqDecoratedTask.")
534535

535-
sign = get_type_hints(metadata_source)
536+
hint_source = metadata_source
537+
if not hasattr(hint_source, "__annotations__"):
538+
hint_source = cast(Any, metadata_source).__call__
539+
sign = get_type_hints(hint_source)
536540
return_type = None
537541
if "return" in sign:
538542
return_type = sign["return"]
539543

540-
decorated_task = wraps(metadata_source)(
544+
assigned_metadata = tuple(
545+
attribute
546+
for attribute in WRAPPER_ASSIGNMENTS
547+
if hasattr(metadata_source, attribute)
548+
)
549+
decorated_task = update_wrapper(
541550
task_cls(
542551
broker=self,
543552
original_func=func,
544553
labels=labels,
545554
task_name=real_task_name,
546555
return_type=return_type, # type: ignore
547556
),
557+
metadata_source,
558+
assigned=assigned_metadata,
548559
)
549560

550561
if register:

tests/abc/test_broker.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ async def test_func() -> None:
6868
}
6969

7070

71+
def test_decorator_generates_unique_lambda_task_names() -> None:
72+
"""Anonymous tasks must not collide in the broker task registry."""
73+
broker = _TestBroker()
74+
75+
first_task = broker.task(lambda: None)
76+
second_task = broker.task(lambda: None)
77+
78+
assert ":lambda_" in first_task.task_name
79+
assert ":lambda_" in second_task.task_name
80+
assert first_task.task_name != second_task.task_name
81+
82+
7183
def test_kicker_labels_modification() -> None:
7284
"""Test that using kicker.with_labels doesn't modify task's labels globally."""
7385
broker = _TestBroker()

tests/brokers/test_inmemory.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import asyncio
2+
import logging
23
import uuid
34
from typing import Any
45

56
import pytest
67

7-
from taskiq import Flow, InMemoryBroker
8+
from taskiq import Flow, InMemoryBroker, TaskiqMessage
89
from taskiq.abc.middleware import TaskiqMiddleware
910
from taskiq.brokers.inmemory_broker import InmemoryResultBackend
1011
from taskiq.events import TaskiqEvents
@@ -39,16 +40,18 @@ async def shutdown(self) -> None:
3940

4041

4142
class RecordingResultBackend(InmemoryResultBackend[Any]):
42-
"""Record result backend lifecycle calls and optional startup failure."""
43+
"""Record result backend lifecycle calls and optional failures."""
4344

4445
def __init__(
4546
self,
4647
events: list[str],
4748
startup_error: Exception | None = None,
49+
shutdown_error: Exception | None = None,
4850
) -> None:
4951
super().__init__()
5052
self.events = events
5153
self.startup_error = startup_error
54+
self.shutdown_error = shutdown_error
5255

5356
async def startup(self) -> None:
5457
"""Record startup and raise the configured error."""
@@ -59,6 +62,20 @@ async def startup(self) -> None:
5962
async def shutdown(self) -> None:
6063
"""Record shutdown."""
6164
self.events.append("backend.shutdown")
65+
if self.shutdown_error is not None:
66+
raise self.shutdown_error
67+
68+
69+
class FailingExecutionMiddleware(TaskiqMiddleware):
70+
"""Raise before execution to expose background callback failures."""
71+
72+
def __init__(self, error: Exception) -> None:
73+
super().__init__()
74+
self.error = error
75+
76+
def pre_execute(self, message: TaskiqMessage) -> TaskiqMessage:
77+
"""Raise the configured callback failure."""
78+
raise self.error
6279

6380

6481
async def test_inmemory_success() -> None:
@@ -197,6 +214,48 @@ async def test_shutdown_closes_remaining_resources_after_middleware_failure() ->
197214
broker.executor.submit(int)
198215

199216

217+
async def test_shutdown_keeps_first_failure_and_closes_every_resource(
218+
caplog: pytest.LogCaptureFixture,
219+
) -> None:
220+
events: list[str] = []
221+
event_error = LifecycleError("event shutdown failed")
222+
middleware_error = LifecycleError("middleware shutdown failed")
223+
backend_error = LifecycleError("backend shutdown failed")
224+
broker = InMemoryBroker()
225+
broker.with_middlewares(
226+
RecordingLifecycleMiddleware(events, shutdown_error=middleware_error),
227+
)
228+
broker.with_result_backend(
229+
RecordingResultBackend(events, shutdown_error=backend_error),
230+
)
231+
232+
@broker.on_event(TaskiqEvents.CLIENT_SHUTDOWN)
233+
def fail_client_shutdown(state: TaskiqState) -> None:
234+
events.append("client.shutdown")
235+
raise event_error
236+
237+
@broker.on_event(TaskiqEvents.WORKER_SHUTDOWN)
238+
def record_worker_shutdown(state: TaskiqState) -> None:
239+
events.append("worker.shutdown")
240+
241+
caplog.set_level(logging.ERROR, logger="taskiq")
242+
await broker.startup()
243+
244+
with pytest.raises(LifecycleError) as exc_info:
245+
await broker.shutdown()
246+
247+
assert exc_info.value is event_error
248+
assert events == [
249+
"middleware.startup",
250+
"backend.startup",
251+
"client.shutdown",
252+
"worker.shutdown",
253+
"middleware.shutdown",
254+
"backend.shutdown",
255+
]
256+
assert caplog.text.count("Additional error while shutting down") == 2
257+
258+
200259
async def test_shutdown() -> None:
201260
broker = InMemoryBroker()
202261
test_value = uuid.uuid4().hex
@@ -265,6 +324,54 @@ async def test_task() -> None:
265324
assert not broker._running_tasks
266325

267326

327+
async def test_wait_all_propagates_task_failure() -> None:
328+
task_error = LifecycleError("in-memory task failed")
329+
broker = InMemoryBroker()
330+
broker.with_middlewares(FailingExecutionMiddleware(task_error))
331+
332+
@broker.task
333+
async def failing_task() -> None:
334+
return None
335+
336+
await failing_task.kiq()
337+
338+
with pytest.raises(LifecycleError) as exc_info:
339+
await broker.wait_all()
340+
341+
assert exc_info.value is task_error
342+
assert not broker._running_tasks
343+
344+
345+
async def test_shutdown_cleans_resources_after_task_failure() -> None:
346+
events: list[str] = []
347+
task_error = LifecycleError("in-memory task failed")
348+
broker = InMemoryBroker()
349+
broker.with_middlewares(RecordingLifecycleMiddleware(events))
350+
broker.with_middlewares(FailingExecutionMiddleware(task_error))
351+
broker.with_result_backend(RecordingResultBackend(events))
352+
353+
@broker.task
354+
async def failing_task() -> None:
355+
return None
356+
357+
await broker.startup()
358+
await failing_task.kiq()
359+
360+
with pytest.raises(LifecycleError) as exc_info:
361+
await broker.shutdown()
362+
363+
assert exc_info.value is task_error
364+
assert events == [
365+
"middleware.startup",
366+
"backend.startup",
367+
"middleware.shutdown",
368+
"backend.shutdown",
369+
]
370+
assert not broker._running_tasks
371+
with pytest.raises(RuntimeError, match="cannot schedule new futures"):
372+
broker.executor.submit(int)
373+
374+
268375
async def test_shutdown_waits_for_running_tasks_before_resource_cleanup() -> None:
269376
task_started = asyncio.Event()
270377
finish_task = asyncio.Event()

tests/cli/worker/test_broker_selection.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import asyncio
12
import logging
3+
from concurrent.futures import Executor
24

35
import pytest
46

57
from taskiq import InMemoryBroker, TaskiqRouter
8+
from taskiq.acks import AcknowledgeType
69
from taskiq.cli.worker.args import WorkerArgs
7-
from taskiq.cli.worker.run import get_broker_selection, shutdown_broker
10+
from taskiq.cli.worker.run import get_broker_selection, shutdown_broker, start_listen
11+
from taskiq.receiver import Receiver
12+
from taskiq.receiver.runtime import BrokerSelection, WorkerRuntimeOptions
813
from taskiq.warnings import TaskiqDeprecationWarning
914

1015

@@ -17,6 +22,103 @@ def test_get_broker_selection_preserves_direct_single_broker() -> None:
1722
assert not selection.explicit_group
1823

1924

25+
def test_start_listen_builds_runtime_from_worker_arguments(
26+
monkeypatch: pytest.MonkeyPatch,
27+
) -> None:
28+
broker = InMemoryBroker()
29+
args = WorkerArgs(
30+
broker=broker,
31+
modules=["application.tasks"],
32+
tasks_pattern=("**/jobs.py",),
33+
fs_discover=True,
34+
configure_logging=False,
35+
max_threadpool_threads=2,
36+
no_parse=True,
37+
shutdown_timeout=0.7,
38+
max_async_tasks=7,
39+
max_async_tasks_jitter=2,
40+
receiver_arg=[("dependency", "value")],
41+
max_prefetch=3,
42+
no_propagate_errors=True,
43+
ack_type=AcknowledgeType.WHEN_RECEIVED,
44+
max_tasks_per_child=11,
45+
wait_tasks_timeout=0.5,
46+
)
47+
loop = asyncio.new_event_loop()
48+
captured_selection: BrokerSelection | None = None
49+
captured_receiver_type: type[Receiver] | None = None
50+
captured_executor: Executor | None = None
51+
captured_options: WorkerRuntimeOptions | None = None
52+
runtime_ran = False
53+
import_calls: list[tuple[list[str], tuple[str, ...], bool]] = []
54+
55+
class RecordingRuntime:
56+
def __init__(
57+
self,
58+
*,
59+
selection: BrokerSelection,
60+
receiver_type: type[Receiver],
61+
executor: Executor,
62+
options: WorkerRuntimeOptions,
63+
) -> None:
64+
nonlocal captured_selection
65+
nonlocal captured_receiver_type
66+
nonlocal captured_executor
67+
nonlocal captured_options
68+
captured_selection = selection
69+
captured_receiver_type = receiver_type
70+
captured_executor = executor
71+
captured_options = options
72+
73+
async def run(self, finish_event: asyncio.Event) -> None:
74+
nonlocal runtime_ran
75+
runtime_ran = True
76+
finish_event.set()
77+
78+
def record_import_tasks(
79+
modules: list[str],
80+
task_pattern: tuple[str, ...],
81+
fs_discover: bool,
82+
) -> None:
83+
import_calls.append((modules, task_pattern, fs_discover))
84+
85+
monkeypatch.setattr("taskiq.cli.worker.run.uvloop", None)
86+
monkeypatch.setattr("taskiq.cli.worker.run.signal.signal", lambda *args: None)
87+
monkeypatch.setattr("taskiq.cli.worker.run.asyncio.new_event_loop", lambda: loop)
88+
monkeypatch.setattr("taskiq.cli.worker.run.import_tasks", record_import_tasks)
89+
monkeypatch.setattr(
90+
"taskiq.cli.worker.run.get_receiver_type",
91+
lambda args: Receiver,
92+
)
93+
monkeypatch.setattr("taskiq.cli.worker.run.WorkerRuntime", RecordingRuntime)
94+
95+
try:
96+
start_listen(args)
97+
finally:
98+
asyncio.set_event_loop(None)
99+
loop.close()
100+
101+
assert captured_selection is not None
102+
assert captured_selection.listeners == (broker,)
103+
assert broker.is_worker_process
104+
assert captured_receiver_type is Receiver
105+
assert captured_executor is not None
106+
assert captured_options == WorkerRuntimeOptions(
107+
validate_params=False,
108+
max_async_tasks=7,
109+
max_async_tasks_jitter=2,
110+
max_prefetch=3,
111+
propagate_exceptions=False,
112+
ack_type=AcknowledgeType.WHEN_RECEIVED,
113+
max_tasks_to_execute=11,
114+
wait_tasks_timeout=0.5,
115+
shutdown_timeout=0.7,
116+
receiver_kwargs={"dependency": "value"},
117+
)
118+
assert import_calls == [(["application.tasks"], ("**/jobs.py",), True)]
119+
assert runtime_ran
120+
121+
20122
@pytest.mark.parametrize("use_factory", [False, True])
21123
def test_get_broker_selection_preserves_imported_single_broker(
22124
monkeypatch: pytest.MonkeyPatch,
@@ -67,6 +169,25 @@ async def shutdown(self) -> None:
67169
assert diagnostic not in caplog.text
68170

69171

172+
async def test_legacy_shutdown_broker_helper_preserves_cancellation() -> None:
173+
shutdown_error = asyncio.CancelledError("adapter shutdown cancelled")
174+
175+
class CancelledShutdownBroker(InMemoryBroker):
176+
async def shutdown(self) -> None:
177+
raise shutdown_error
178+
179+
broker = CancelledShutdownBroker()
180+
181+
with (
182+
pytest.warns(
183+
TaskiqDeprecationWarning,
184+
match="WorkerRuntime",
185+
),
186+
pytest.raises(asyncio.CancelledError),
187+
):
188+
await shutdown_broker(broker, timeout=1)
189+
190+
70191
@pytest.mark.parametrize("use_factory", [False, True])
71192
def test_get_broker_selection_imports_explicit_listener_sequence(
72193
monkeypatch: pytest.MonkeyPatch,

tests/receiver/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from collections.abc import Iterator
2+
from concurrent.futures import ThreadPoolExecutor
3+
4+
import pytest
5+
6+
7+
@pytest.fixture
8+
def runtime_executor() -> Iterator[ThreadPoolExecutor]:
9+
"""Provide the process-wide sync executor owned by a worker runtime."""
10+
with ThreadPoolExecutor(max_workers=2) as executor:
11+
yield executor

0 commit comments

Comments
 (0)