1+ import contextlib
12import signal
23import sys
34import time
4- from typing import TYPE_CHECKING , Any , Optional
5+ from typing import TYPE_CHECKING , Any , Callable , Optional
56
67if TYPE_CHECKING :
8+ import asyncio
79 import multiprocessing
10+ from collections .abc import Collection
811
912 from click import Group
1013 from litestar import Litestar
1316 from litestar_saq .base import Worker
1417 from litestar_saq .plugin import SAQPlugin
1518
19+ # Default timeout for graceful shutdown when no grace period is configured
20+ DEFAULT_SHUTDOWN_TIMEOUT = 5.0
21+ # Extra buffer time to allow for signal propagation and cleanup
22+ SHUTDOWN_BUFFER = 2.0
23+
24+
25+ def get_max_shutdown_timeout (workers : "Collection[Worker]" ) -> float :
26+ """Calculate the maximum shutdown timeout from worker configurations.
27+
28+ The timeout is the maximum of all workers' shutdown_grace_period_s plus
29+ a buffer for signal propagation. Falls back to DEFAULT_SHUTDOWN_TIMEOUT
30+ if no grace periods are configured.
31+
32+ Args:
33+ workers: Collection of worker instances.
34+
35+ Returns:
36+ Maximum shutdown timeout in seconds.
37+ """
38+ grace_periods : list [float ] = []
39+ for worker in workers :
40+ grace_period = getattr (worker , "_shutdown_grace_period_s" , None )
41+ if grace_period is not None :
42+ grace_periods .append (grace_period )
43+ if grace_periods :
44+ return max (grace_periods ) + SHUTDOWN_BUFFER
45+ return DEFAULT_SHUTDOWN_TIMEOUT
46+
1647
1748def _terminate_worker_processes (
1849 processes : "list[multiprocessing.Process]" ,
19- timeout : float = 5.0 ,
50+ timeout : float = DEFAULT_SHUTDOWN_TIMEOUT ,
2051) -> None :
2152 """Gracefully terminate worker processes with timeout.
2253
2354 Args:
2455 processes: List of worker processes to terminate
25- timeout: Maximum time to wait for graceful shutdown in seconds
56+ timeout: Maximum time to wait for graceful shutdown in seconds.
57+ Should be at least as long as the worker's shutdown_grace_period_s
58+ plus buffer time for signal propagation.
2659 """
2760 from litestar .cli ._utils import console # pyright: ignore
2861
@@ -48,6 +81,101 @@ def _terminate_worker_processes(
4881 console .print (f"[red]Error killing worker process: { p .name } [/]" )
4982
5083
84+ def _get_event_loop () -> "asyncio.AbstractEventLoop" :
85+ import asyncio
86+
87+ try :
88+ return asyncio .get_event_loop ()
89+ except RuntimeError :
90+ loop = asyncio .new_event_loop ()
91+ asyncio .set_event_loop (loop )
92+ return loop
93+
94+
95+ def _register_shutdown_handlers (
96+ loop : "asyncio.AbstractEventLoop" ,
97+ shutdown_event : "asyncio.Event" ,
98+ ) -> Callable [[], None ]:
99+ signal_handlers_registered = False
100+ fallback_handlers_registered = False
101+ original_sigterm : Any = None
102+ original_sigint : Any = None
103+
104+ def request_shutdown () -> None :
105+ shutdown_event .set ()
106+
107+ try :
108+ loop .add_signal_handler (signal .SIGTERM , request_shutdown )
109+ loop .add_signal_handler (signal .SIGINT , request_shutdown )
110+ signal_handlers_registered = True
111+ except (NotImplementedError , RuntimeError ):
112+ original_sigterm = signal .getsignal (signal .SIGTERM )
113+ original_sigint = signal .getsignal (signal .SIGINT )
114+
115+ def fallback_handler (_signum : int , _frame : Any ) -> None :
116+ loop .call_soon_threadsafe (shutdown_event .set )
117+
118+ try :
119+ signal .signal (signal .SIGTERM , fallback_handler )
120+ signal .signal (signal .SIGINT , fallback_handler )
121+ fallback_handlers_registered = True
122+ except ValueError :
123+ fallback_handlers_registered = False
124+
125+ def cleanup () -> None :
126+ if signal_handlers_registered :
127+ loop .remove_signal_handler (signal .SIGTERM )
128+ loop .remove_signal_handler (signal .SIGINT )
129+ return
130+ if not fallback_handlers_registered :
131+ return
132+ if original_sigterm is not None :
133+ with contextlib .suppress (ValueError ):
134+ signal .signal (signal .SIGTERM , original_sigterm )
135+ if original_sigint is not None :
136+ with contextlib .suppress (ValueError ):
137+ signal .signal (signal .SIGINT , original_sigint )
138+
139+ return cleanup
140+
141+
142+ async def _run_worker_with_shutdown (worker : "Worker" ) -> None :
143+ import asyncio
144+
145+ loop = asyncio .get_running_loop ()
146+ shutdown_event = asyncio .Event ()
147+ cleanup_handlers = _register_shutdown_handlers (loop , shutdown_event )
148+
149+ try :
150+ await worker .queue .connect ()
151+ worker_task = asyncio .create_task (worker .start ())
152+ shutdown_task = asyncio .create_task (shutdown_event .wait ())
153+
154+ done , pending = await asyncio .wait (
155+ [worker_task , shutdown_task ],
156+ return_when = asyncio .FIRST_COMPLETED ,
157+ )
158+
159+ if worker_task in done :
160+ worker_task .result ()
161+
162+ if shutdown_event .is_set ():
163+ await worker .stop ()
164+ if not worker_task .done ():
165+ worker_task .cancel ()
166+ with contextlib .suppress (asyncio .CancelledError ):
167+ await worker_task
168+ pending .discard (worker_task )
169+
170+ for task in pending :
171+ task .cancel ()
172+ with contextlib .suppress (asyncio .CancelledError ):
173+ await task
174+ finally :
175+ await worker .queue .disconnect ()
176+ cleanup_handlers ()
177+
178+
51179def build_cli_app () -> "Group" : # noqa: C901, PLR0915
52180 import asyncio
53181 import multiprocessing
@@ -106,12 +234,13 @@ def run_worker( # pyright: ignore[reportUnusedFunction]
106234 show_saq_info (app , workers , plugin )
107235 managed_workers = list (plugin .get_workers ().values ())
108236 processes : list [multiprocessing .Process ] = []
237+ shutdown_timeout = get_max_shutdown_timeout (managed_workers )
109238
110239 def handle_shutdown_signal (signum : int , _frame : Any ) -> None :
111240 """Handle shutdown signals (SIGTERM/SIGINT) for graceful shutdown."""
112241 sig_name = "SIGTERM" if signum == signal .SIGTERM else "SIGINT"
113- console .print (f"[yellow]Received { sig_name } , stopping workers...[/]" )
114- _terminate_worker_processes (processes )
242+ console .print (f"[yellow]Received { sig_name } , stopping workers (timeout: { shutdown_timeout :.1f } s) ...[/]" )
243+ _terminate_worker_processes (processes , timeout = shutdown_timeout )
115244 loop = asyncio .get_event_loop ()
116245 for w in managed_workers :
117246 loop .run_until_complete (w .stop ())
@@ -228,15 +357,9 @@ def run_saq_worker(worker: "Worker", logging_config: "Optional[BaseLoggingConfig
228357 worker: The worker instance to run.
229358 logging_config: Optional logging configuration to apply.
230359 """
231- import asyncio
232-
233360 from litestar .logging .config import StructLoggingConfig
234361
235- try :
236- loop = asyncio .get_event_loop ()
237- except RuntimeError :
238- loop = asyncio .new_event_loop ()
239- asyncio .set_event_loop (loop )
362+ loop = _get_event_loop ()
240363
241364 if logging_config is not None :
242365 logging_config .configure ()
@@ -246,24 +369,10 @@ def run_saq_worker(worker: "Worker", logging_config: "Optional[BaseLoggingConfig
246369 if worker .separate_process :
247370 worker .configure_structlog_context ()
248371 if isinstance (logging_config , StructLoggingConfig ) and logging_config .standard_lib_logging_config is not None :
249- _ = logging_config .standard_lib_logging_config .configure ()
250-
251- def handle_sigterm (_signum : int , _frame : Any ) -> None :
252- """Handle SIGTERM in worker process."""
253- loop .run_until_complete (loop .create_task (worker .stop ()))
254- sys .exit (0 )
255-
256- signal .signal (signal .SIGTERM , handle_sigterm )
257-
258- async def worker_start (w : "Worker" ) -> None :
259- try :
260- await w .queue .connect ()
261- await w .start ()
262- finally :
263- await w .queue .disconnect ()
372+ logging_config .standard_lib_logging_config .configure ()
264373
265374 try :
266375 if worker .separate_process :
267- loop .run_until_complete (loop . create_task ( worker_start ( worker ) ))
376+ loop .run_until_complete (_run_worker_with_shutdown ( worker ))
268377 except KeyboardInterrupt :
269378 loop .run_until_complete (loop .create_task (worker .stop ()))
0 commit comments