@@ -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+
178210async 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." )
0 commit comments