Skip to content

Commit 0a8409a

Browse files
authored
fix: Resolve GitHub issues #76, #75, and #74 (#80)
* fix: Resolve GitHub issues #76, #75, and #74 Issue #76: Remove trailing slash from root_path in web UI - SAQ's app.js expects root_path without trailing slash - Prevents double-slash URLs like /saq// Issue #75: Fix Redis async client detection - Check both redis.asyncio.Redis and redis.Redis instances - Supports pre-instantiated async Redis clients Issue #74: Asyncio-first signal handling - Use loop.add_signal_handler() instead of signal.signal() - Handle both SIGTERM and SIGINT in worker subprocess - Use asyncio.Event() for non-blocking shutdown coordination - Properly cleanup signal handlers and cancel pending tasks Closes #76, closes #75, closes #74 * fix: Respect shutdown_grace_period_s during worker termination The parent process now waits for the configured grace period before force-killing worker subprocesses. This ensures jobs have sufficient time to complete gracefully when shutdown_grace_period_s is configured. Changes: - Add get_max_shutdown_timeout() to calculate timeout from workers - Use maximum grace period + 2s buffer for termination timeout - Display configured timeout in shutdown messages - Update both CLI and plugin shutdown handlers
1 parent 0a29914 commit 0a8409a

8 files changed

Lines changed: 267 additions & 85 deletions

File tree

litestar_saq/cli.py

Lines changed: 137 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import contextlib
12
import signal
23
import sys
34
import time
4-
from typing import TYPE_CHECKING, Any, Optional
5+
from typing import TYPE_CHECKING, Any, Callable, Optional
56

67
if 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
@@ -13,16 +16,46 @@
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

1748
def _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+
51179
def 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()))

litestar_saq/config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,9 @@ def broker_type(self) -> 'Literal["redis", "postgres", "http"]':
427427
if self._broker_type is None and self.broker_instance is not None:
428428
if self._is_instance_of(self.broker_instance, "psycopg_pool", "AsyncConnectionPool"):
429429
self._broker_type = "postgres"
430-
elif self._is_instance_of(self.broker_instance, "redis", "Redis"):
430+
elif self._is_instance_of(self.broker_instance, "redis.asyncio", "Redis") or self._is_instance_of(
431+
self.broker_instance, "redis", "Redis"
432+
):
431433
self._broker_type = "redis"
432434
elif self._is_instance_of(self.broker_instance, "saq.queue.http", "HttpQueue"):
433435
self._broker_type = "http"
@@ -464,7 +466,9 @@ def queue_class(self) -> "type[Queue]":
464466
from saq.queue.postgres import PostgresQueue
465467

466468
self._queue_class = PostgresQueue
467-
elif self._is_instance_of(self.broker_instance, "redis", "Redis"):
469+
elif self._is_instance_of(self.broker_instance, "redis.asyncio", "Redis") or self._is_instance_of(
470+
self.broker_instance, "redis", "Redis"
471+
):
468472
from saq.queue.redis import RedisQueue
469473

470474
self._queue_class = RedisQueue

litestar_saq/controllers.py

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,34 @@
1515

1616

1717
async def job_info(queue: "Queue", job_id: str) -> "Job":
18+
"""Return a job
19+
20+
Raises:
21+
NotFoundException: If the queue or job is not found.
22+
"""
1823
job = await queue.job(job_id)
1924
if not job:
2025
msg = f"Could not find job ID {job_id}"
2126
raise NotFoundException(msg)
2227
return cast("Job", job)
2328

2429

30+
def get_queue_or_404(task_queues: "TaskQueues", queue_id: str) -> "Queue":
31+
"""Return a queue or raise NotFoundException."""
32+
queue = task_queues.queues.get(queue_id)
33+
if queue is None:
34+
msg = f"Could not find the {queue_id} queue"
35+
raise NotFoundException(msg)
36+
return queue
37+
38+
2539
@lru_cache(typed=True)
26-
def build_controller( # noqa: C901
40+
def build_controller(
2741
url_base: str = "/saq",
2842
controller_guards: "Optional[list[Guard]]" = None, # pyright: ignore[reportUnknownParameterType]
2943
include_in_schema_: bool = False,
3044
) -> "type[Controller]":
3145
from litestar import Controller, MediaType, Response, get, post
32-
from litestar.exceptions import NotFoundException
3346
from litestar.status_codes import HTTP_202_ACCEPTED, HTTP_500_INTERNAL_SERVER_ERROR
3447

3548
normalized_root = url_base.rstrip("/") or "/saq"
@@ -45,7 +58,7 @@ def build_controller( # noqa: C901
4558
</head>
4659
<body>
4760
<div id="app"></div>
48-
<script>const root_path = "{escaped_root}/";</script>
61+
<script>const root_path = "{escaped_root}";</script>
4962
<script src="{escaped_root}/static/snabbdom.js.gz"></script>
5063
<script src="{escaped_root}/static/app.js"></script>
5164
</body>
@@ -99,10 +112,7 @@ async def queue_detail(self, task_queues: "TaskQueues", queue_id: str) -> "dict[
99112
Returns:
100113
The queue information.
101114
"""
102-
queue = task_queues.get(queue_id)
103-
if not queue:
104-
msg = f"Could not find the {queue_id} queue"
105-
raise NotFoundException(msg)
115+
queue = get_queue_or_404(task_queues, queue_id)
106116
return {"queue": await queue.info(jobs=True)}
107117

108118
@get(
@@ -130,10 +140,7 @@ async def job_detail(
130140
Returns:
131141
The job information.
132142
"""
133-
queue = task_queues.get(queue_id)
134-
if not queue:
135-
msg = f"Could not find the {queue_id} queue"
136-
raise NotFoundException(msg)
143+
queue = get_queue_or_404(task_queues, queue_id)
137144
job = await job_info(queue, job_id)
138145
job_dict = job.to_dict()
139146
if "kwargs" in job_dict:
@@ -160,16 +167,10 @@ async def job_retry(self, task_queues: "TaskQueues", queue_id: str, job_id: str)
160167
queue_id: The queue ID.
161168
job_id: The job ID.
162169
163-
Raises:
164-
NotFoundException: If the queue or job is not found.
165-
166170
Returns:
167171
The job information.
168172
"""
169-
queue = task_queues.get(queue_id)
170-
if not queue:
171-
msg = f"Could not find the {queue_id} queue"
172-
raise NotFoundException(msg)
173+
queue = get_queue_or_404(task_queues, queue_id)
173174
job = await job_info(queue, job_id)
174175
await job.retry("retried from ui")
175176
return {}
@@ -198,10 +199,7 @@ async def job_abort(self, task_queues: "TaskQueues", queue_id: str, job_id: str)
198199
Returns:
199200
The job information.
200201
"""
201-
queue = task_queues.get(queue_id)
202-
if not queue:
203-
msg = f"Could not find the {queue_id} queue"
204-
raise NotFoundException(msg)
202+
queue = get_queue_or_404(task_queues, queue_id)
205203
job = await job_info(queue, job_id)
206204
await job.abort("aborted from ui")
207205
return {}
@@ -236,7 +234,6 @@ async def health(self, task_queues: "TaskQueues") -> Response[str]:
236234
status_code=HTTP_500_INTERNAL_SERVER_ERROR,
237235
)
238236

239-
# static site
240237
@get(
241238
[
242239
url_base,

litestar_saq/decorators.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ async def _heartbeat_loop(job: "Optional[Job]", interval: float) -> None:
170170
return
171171

172172
job_id = getattr(job, "id", "unknown")
173+
logger.info(
174+
"Heartbeat monitoring started for job %s (interval: %.1fs)",
175+
job_id,
176+
interval,
177+
extra={
178+
"job_id": job_id,
179+
"event": "heartbeat_started",
180+
"interval": interval,
181+
},
182+
)
173183

174184
try:
175185
while True:

0 commit comments

Comments
 (0)