Skip to content

Commit fb21919

Browse files
committed
Merge remote-tracking branch 'origin/master' into fix/scheduler-invalid-cron-offset-crash
2 parents bc3b33d + b5d6033 commit fb21919

7 files changed

Lines changed: 328 additions & 1 deletion

File tree

‎docs/available-components/brokers.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ Project link: [taskiq-nats](https://github.com/taskiq-python/taskiq-nats).
112112
pip install taskiq-nats
113113
```
114114

115+
### SQS broker
116+
117+
Project link: [taskiq-sqs](https://github.com/taskiq-python/taskiq-sqs).
118+
119+
```bash
120+
pip install taskiq-sqs
121+
```
122+
123+
### Kafka broker
124+
125+
Project link: [taskiq-aio-kafka](https://github.com/taskiq-python/taskiq-aio-kafka).
126+
127+
```bash
128+
pip install taskiq-aio-kafka
129+
```
130+
115131
## Third-party brokers
116132

117133
These brokers are not part of the core Taskiq library. They are maintained by other open‑source contributors. You can install them as a separate packages.
@@ -141,3 +157,11 @@ Project link: [taskiq-ydb](https://github.com/danfimov/taskiq-ydb).
141157
```bash
142158
pip install taskiq-ydb
143159
```
160+
161+
### Azure Service Bus broker
162+
163+
Project link: [taskiq-service-bus](https://github.com/hamiltonkibbe/taskiq-service-bus).
164+
165+
```bash
166+
pip install taskiq-service-bus
167+
```

‎docs/available-components/result-backends.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@ Project link: [taskiq-nats](https://github.com/taskiq-python/taskiq-nats).
4040
pip install taskiq-nats
4141
```
4242

43+
### S3 result backend
44+
45+
Project link: [taskiq-sqs](https://github.com/taskiq-python/taskiq-sqs). Provides `S3ResultBackend`.
46+
47+
```bash
48+
pip install taskiq-sqs
49+
```
50+
4351
## Third-party result backends
4452

4553
These result backends are not part of the core Taskiq library. They are maintained by other open‑source contributors. You can install them as a separate packages.

‎taskiq/brokers/inmemory_broker.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, TypeVar
66

77
from taskiq.abc.broker import AsyncBroker
8+
from taskiq.abc.middleware import TaskiqMiddleware
89
from taskiq.abc.result_backend import AsyncResultBackend, TaskiqResult
910
from taskiq.depends.progress_tracker import TaskProgress
1011
from taskiq.events import TaskiqEvents
@@ -198,9 +199,21 @@ async def startup(self) -> None:
198199
for handler in self.event_handlers.get(event, []):
199200
await maybe_awaitable(handler(self.state))
200201

202+
for middleware in self.middlewares:
203+
if middleware.__class__.startup != TaskiqMiddleware.startup:
204+
await maybe_awaitable(middleware.startup())
205+
206+
await self.result_backend.startup()
207+
201208
async def shutdown(self) -> None:
202209
"""Runs shutdown events for client and worker side."""
203210
for event in (TaskiqEvents.CLIENT_SHUTDOWN, TaskiqEvents.WORKER_SHUTDOWN):
204211
for handler in self.event_handlers.get(event, []):
205212
await maybe_awaitable(handler(self.state))
213+
214+
for middleware in self.middlewares:
215+
if middleware.__class__.shutdown != TaskiqMiddleware.shutdown:
216+
await maybe_awaitable(middleware.shutdown())
217+
218+
await self.result_backend.shutdown()
206219
self.executor.shutdown()

‎taskiq/cli/scheduler/args.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class SchedulerArgs:
2020
skip_first_run: bool = False
2121
update_interval: int | None = None
2222
loop_interval: int | None = None
23+
send_timeout: float | None = None
2324

2425
@classmethod
2526
def from_cli(cls, args: Sequence[str] | None = None) -> "SchedulerArgs":
@@ -111,6 +112,19 @@ def from_cli(cls, args: Sequence[str] | None = None) -> "SchedulerArgs":
111112
"If not specified, scheduler will run once a second."
112113
),
113114
)
115+
parser.add_argument(
116+
"--send-timeout",
117+
type=float,
118+
default=None,
119+
help=(
120+
"Optional per-send timeout (seconds). If set, each spawned send "
121+
"task is wrapped in asyncio.wait_for with this timeout, "
122+
"preventing a single hung broker.kick from permanently blocking "
123+
"subsequent ticks for the same schedule_id. On timeout the send "
124+
"is cancelled, a warning is logged, and the next scheduled tick "
125+
"will retry. Default: no timeout (backwards-compatible)."
126+
),
127+
)
114128

115129
namespace = parser.parse_args(args)
116130
# If there are any patterns specified, remove default.

‎taskiq/cli/scheduler/run.py‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,38 @@ async def send(
175175
await scheduler.on_ready(source, task)
176176

177177

178+
async def send_with_timeout(
179+
scheduler: TaskiqScheduler,
180+
source: ScheduleSource,
181+
task: ScheduledTask,
182+
timeout: float,
183+
) -> None:
184+
"""
185+
Send a task, cancelling it if it does not complete within ``timeout`` seconds.
186+
187+
A timed-out send is logged at WARNING and swallowed — re-raising would propagate
188+
out of the scheduler's main loop's ``add_done_callback``, which is not what we
189+
want. Suppressing it lets the done_callback clear ``running_schedules`` so the
190+
next cron boundary can re-dispatch the task. The slot is freed; the message is
191+
dropped (the broker did not acknowledge it within the budget).
192+
193+
:param scheduler: current scheduler.
194+
:param source: source of the task.
195+
:param task: task to send.
196+
:param timeout: seconds to wait before cancelling the send. Must be > 0.
197+
"""
198+
try:
199+
await asyncio.wait_for(send(scheduler, source, task), timeout=timeout)
200+
except asyncio.TimeoutError:
201+
logger.warning(
202+
"Sending task %s with schedule_id %s timed out after %.1fs "
203+
"and was cancelled. The next scheduled tick will retry.",
204+
task.task_name,
205+
task.schedule_id,
206+
timeout,
207+
)
208+
209+
178210
async def _sleep_until_next_second() -> None:
179211
now = datetime.now(tz=timezone.utc)
180212
await asyncio.sleep(1 - now.microsecond / 1_000_000)
@@ -293,6 +325,7 @@ async def run(
293325
update_interval: timedelta | None = None,
294326
loop_interval: timedelta | None = None,
295327
skip_first_run: bool = False,
328+
send_timeout: float | None = None,
296329
) -> None:
297330
"""
298331
Runs scheduler loop.
@@ -304,11 +337,19 @@ async def run(
304337
:param loop_interval: interval to check tasks to send.
305338
:param skip_first_run: Wait for the beginning of the next minute
306339
to skip the first run.
340+
:param send_timeout: optional per-send timeout (seconds). If set, each
341+
spawned send task is wrapped in :func:`asyncio.wait_for` with this
342+
timeout, preventing a single hung ``broker.kick`` from permanently
343+
blocking subsequent ticks of the same ``schedule_id`` via the
344+
``running_schedules`` skip check. Default ``None`` (no timeout —
345+
backwards-compatible behavior).
307346
"""
308347
if update_interval is None:
309348
update_interval = timedelta(minutes=1)
310349
if loop_interval is None:
311350
loop_interval = timedelta(seconds=1)
351+
if send_timeout is not None and send_timeout <= 0:
352+
raise ValueError("send_timeout must be > 0 when provided")
312353

313354
running_schedules: dict[ScheduleId, asyncio.Task[Any]] = {}
314355

@@ -336,8 +377,17 @@ async def run(
336377
)
337378

338379
if is_ready_to_send and task.schedule_id not in running_schedules:
380+
if send_timeout is not None:
381+
send_coro = send_with_timeout(
382+
self.scheduler,
383+
source,
384+
task,
385+
timeout=send_timeout,
386+
)
387+
else:
388+
send_coro = send(self.scheduler, source, task)
339389
send_task = self._event_loop.create_task(
340-
send(self.scheduler, source, task),
390+
send_coro,
341391
# We need to set the name of the task
342392
# to be able to discard its reference
343393
# after it is done.
@@ -413,6 +463,7 @@ async def run_scheduler(args: SchedulerArgs) -> None:
413463
update_interval=update_interval,
414464
loop_interval=loop_interval,
415465
skip_first_run=args.skip_first_run,
466+
send_timeout=args.send_timeout,
416467
)
417468
except asyncio.CancelledError:
418469
logger.info("Shutting down scheduler.")

‎tests/brokers/test_inmemory.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
from taskiq import InMemoryBroker
7+
from taskiq.abc.middleware import TaskiqMiddleware
78
from taskiq.events import TaskiqEvents
89
from taskiq.state import TaskiqState
910

@@ -66,6 +67,24 @@ async def _c_startup(state: TaskiqState) -> None:
6667
assert broker.state.from_client == test_value
6768

6869

70+
async def test_middleware_startup_and_shutdown_fire() -> None:
71+
calls = []
72+
73+
class RecordingMiddleware(TaskiqMiddleware):
74+
async def startup(self) -> None:
75+
calls.append("startup")
76+
77+
async def shutdown(self) -> None:
78+
calls.append("shutdown")
79+
80+
broker = InMemoryBroker().with_middlewares(RecordingMiddleware())
81+
82+
await broker.startup()
83+
await broker.shutdown()
84+
85+
assert calls == ["startup", "shutdown"]
86+
87+
6988
async def test_execution() -> None:
7089
broker = InMemoryBroker()
7190
test_value = uuid.uuid4().hex

0 commit comments

Comments
 (0)