Skip to content

Commit 0a94c74

Browse files
[512] Graceful Shutdown Handlers (#809)
* feat: implement graceful shutdown handlers for Django and Celery Handle SIGTERM/SIGINT by draining in-flight HTTP requests, waiting for Celery tasks (30s timeout), closing DB connections, and logging the shutdown reason. Closes #512. Co-authored-by: Cursor <cursoragent@cursor.com> * test: patch shutdown.connections in close_db test Co-authored-by: Cursor <cursoragent@cursor.com> * fix: add deployment shutdown timeouts and fix DB close test Set gunicorn --graceful-timeout and Celery --stop-timeout to 30s in docker-compose and k8s manifests. Patch soroscan.shutdown.connections in tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: remove unused MagicMock import for ruff Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2ae2a59 commit 0a94c74

12 files changed

Lines changed: 417 additions & 5 deletions

File tree

django-backend/soroscan/asgi.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,7 @@
4848
),
4949
}
5050
)
51+
52+
from soroscan.shutdown import register_shutdown_handlers # noqa: E402
53+
54+
register_shutdown_handlers()

django-backend/soroscan/celery.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
import time
77

88
from celery import Celery
9-
from celery.signals import task_failure, task_postrun, task_prerun
9+
from celery.signals import (
10+
task_failure,
11+
task_postrun,
12+
task_prerun,
13+
worker_shutdown,
14+
worker_shutting_down,
15+
)
1016

1117
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "soroscan.settings")
1218

@@ -63,6 +69,20 @@ def record_celery_task_failure(sender, exception, **kwargs):
6369
).inc()
6470

6571

72+
@worker_shutting_down.connect
73+
def _celery_worker_shutting_down(sender, sig=None, how=None, exitcode=None, **kwargs):
74+
from soroscan.shutdown import on_celery_worker_shutting_down
75+
76+
on_celery_worker_shutting_down(sig=sig, how=how, exitcode=exitcode, **kwargs)
77+
78+
79+
@worker_shutdown.connect
80+
def _celery_worker_shutdown(sender, **kwargs):
81+
from soroscan.shutdown import on_celery_worker_shutdown
82+
83+
on_celery_worker_shutdown(**kwargs)
84+
85+
6686
@app.task(bind=True, ignore_result=True)
6787
def debug_task(self):
6888
print(f"Request: {self.request!r}")

django-backend/soroscan/ingest/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,7 @@ class IngestConfig(AppConfig):
99
def ready(self):
1010
import soroscan.ingest.signals # noqa: F401 — registers signal handlers
1111
from soroscan.operational_metrics import register_operational_collector
12+
from soroscan.shutdown import register_shutdown_handlers
1213

1314
register_operational_collector()
15+
register_shutdown_handlers()
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import json
2+
import signal
3+
import threading
4+
import time
5+
from unittest.mock import patch
6+
7+
import pytest
8+
from django.http import HttpResponse
9+
from django.test import RequestFactory, override_settings
10+
11+
from soroscan.middleware import GracefulShutdownMiddleware
12+
from soroscan.shutdown import (
13+
begin_shutdown,
14+
close_database_connections,
15+
end_request,
16+
in_flight_request_count,
17+
is_shutting_down,
18+
on_celery_worker_shutdown,
19+
on_celery_worker_shutting_down,
20+
perform_graceful_shutdown,
21+
register_shutdown_handlers,
22+
reset_shutdown_state,
23+
try_begin_request,
24+
wait_for_in_flight_requests,
25+
)
26+
27+
28+
@pytest.fixture(autouse=True)
29+
def _clean_shutdown_state():
30+
reset_shutdown_state()
31+
yield
32+
reset_shutdown_state()
33+
34+
35+
@pytest.fixture
36+
def rf():
37+
return RequestFactory()
38+
39+
40+
def test_try_begin_request_tracks_in_flight():
41+
assert try_begin_request() is True
42+
assert in_flight_request_count() == 1
43+
end_request()
44+
assert in_flight_request_count() == 0
45+
46+
47+
def test_try_begin_request_rejects_when_shutting_down():
48+
begin_shutdown("test shutdown")
49+
assert try_begin_request() is False
50+
assert in_flight_request_count() == 0
51+
52+
53+
def test_wait_for_in_flight_requests_completes():
54+
try_begin_request()
55+
56+
def finish_request():
57+
time.sleep(0.05)
58+
end_request()
59+
60+
thread = threading.Thread(target=finish_request)
61+
thread.start()
62+
assert wait_for_in_flight_requests(timeout=2) is True
63+
thread.join()
64+
65+
66+
def test_wait_for_in_flight_requests_times_out():
67+
try_begin_request()
68+
assert wait_for_in_flight_requests(timeout=0.05) is False
69+
end_request()
70+
71+
72+
@override_settings(SHUTDOWN_TIMEOUT_SECONDS=30)
73+
def test_perform_graceful_shutdown_waits_and_closes_db():
74+
try_begin_request()
75+
closed = threading.Event()
76+
77+
def finish_request():
78+
time.sleep(0.05)
79+
end_request()
80+
81+
thread = threading.Thread(target=finish_request)
82+
thread.start()
83+
84+
with patch("soroscan.shutdown.close_database_connections", side_effect=lambda: closed.set()):
85+
with patch("soroscan.shutdown.sys.exit") as mock_exit:
86+
perform_graceful_shutdown("SIGTERM test", exit_process=True)
87+
88+
thread.join()
89+
assert closed.is_set()
90+
assert is_shutting_down()
91+
mock_exit.assert_called_once_with(0)
92+
93+
94+
@override_settings(SHUTDOWN_TIMEOUT_SECONDS=0.05)
95+
def test_perform_graceful_shutdown_logs_timeout():
96+
try_begin_request()
97+
98+
with patch("soroscan.shutdown.close_database_connections"):
99+
with patch("soroscan.shutdown.sys.exit"):
100+
with patch("soroscan.shutdown.logger") as mock_logger:
101+
perform_graceful_shutdown("SIGTERM test", exit_process=True)
102+
103+
assert mock_logger.warning.called
104+
end_request()
105+
106+
107+
def test_perform_graceful_shutdown_is_idempotent():
108+
with patch("soroscan.shutdown.close_database_connections") as mock_close:
109+
with patch("soroscan.shutdown.sys.exit"):
110+
perform_graceful_shutdown("first", exit_process=False)
111+
perform_graceful_shutdown("second", exit_process=False)
112+
113+
mock_close.assert_called_once()
114+
115+
116+
def test_register_shutdown_handlers_installs_sigterm():
117+
register_shutdown_handlers()
118+
handler = signal.getsignal(signal.SIGTERM)
119+
assert handler is not None
120+
assert handler.__name__ == "_handle_shutdown_signal"
121+
122+
123+
def test_middleware_allows_requests_when_healthy(rf):
124+
request = rf.get("/api/health/")
125+
middleware = GracefulShutdownMiddleware(lambda req: HttpResponse("OK", status=200))
126+
response = middleware(request)
127+
assert response.status_code == 200
128+
129+
130+
def test_middleware_rejects_requests_during_shutdown(rf):
131+
begin_shutdown("SIGTERM")
132+
request = rf.get("/api/health/")
133+
middleware = GracefulShutdownMiddleware(lambda req: HttpResponse("OK", status=200))
134+
response = middleware(request)
135+
assert response.status_code == 503
136+
data = json.loads(response.content)
137+
assert data["error"] == "Server is shutting down"
138+
139+
140+
def test_middleware_tracks_in_flight_during_request(rf):
141+
in_flight_during_request = None
142+
143+
def slow_response(req):
144+
nonlocal in_flight_during_request
145+
in_flight_during_request = in_flight_request_count()
146+
return HttpResponse("OK", status=200)
147+
148+
request = rf.get("/api/events/")
149+
middleware = GracefulShutdownMiddleware(slow_response)
150+
middleware(request)
151+
152+
assert in_flight_during_request == 1
153+
assert in_flight_request_count() == 0
154+
155+
156+
def test_close_database_connections():
157+
with patch("soroscan.shutdown.connections") as mock_connections:
158+
close_database_connections()
159+
mock_connections.close_all.assert_called_once()
160+
161+
162+
def test_celery_worker_shutting_down_sets_state():
163+
on_celery_worker_shutting_down(sig="SIGTERM", how="warm", exitcode=0)
164+
assert is_shutting_down()
165+
166+
167+
def test_celery_worker_shutdown_closes_db():
168+
with patch("soroscan.shutdown.close_database_connections") as mock_close:
169+
on_celery_worker_shutdown()
170+
mock_close.assert_called_once()

django-backend/soroscan/middleware.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ def __call__(self, request):
149149

150150
return self.get_response(request)
151151

152+
class GracefulShutdownMiddleware:
153+
"""Reject new requests during shutdown and track in-flight request count."""
154+
155+
def __init__(self, get_response):
156+
self.get_response = get_response
157+
158+
def __call__(self, request):
159+
from soroscan.shutdown import end_request, try_begin_request
160+
161+
if not try_begin_request():
162+
return JsonResponse(
163+
{"error": "Server is shutting down"},
164+
status=503,
165+
)
166+
try:
167+
return self.get_response(request)
168+
finally:
169+
end_request()
170+
171+
152172
class MaintenanceModeMiddleware:
153173
"""Return 503 for all non-admin routes when MAINTENANCE_MODE=True."""
154174

django-backend/soroscan/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def _load_software_version() -> str:
105105
MIDDLEWARE = [
106106
# PrometheusBeforeMiddleware must be first to capture all requests.
107107
"django_prometheus.middleware.PrometheusBeforeMiddleware",
108+
"soroscan.middleware.GracefulShutdownMiddleware",
108109
"soroscan.monitoring.ErrorRateMetricsMiddleware",
109110
"soroscan.middleware.RequestBodySizeMiddleware",
110111
"soroscan.middleware.MaintenanceModeMiddleware",
@@ -296,6 +297,9 @@ def _load_software_version() -> str:
296297
CELERY_TASK_SERIALIZER = "json"
297298
CELERY_RESULT_SERIALIZER = "json"
298299
CELERY_TIMEZONE = TIME_ZONE
300+
# Graceful shutdown: wait up to 30s for active tasks after SIGTERM
301+
CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT = 30
302+
SHUTDOWN_TIMEOUT_SECONDS = env.int("SHUTDOWN_TIMEOUT_SECONDS", default=30)
299303
CELERY_TASK_ROUTES = {
300304
"ingest.tasks.ingest_latest_events": {"queue": "high_priority"},
301305
"ingest.tasks.dispatch_webhook": {"queue": "default"},

django-backend/soroscan/settings_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
MIDDLEWARE = [
3838
"django_prometheus.middleware.PrometheusBeforeMiddleware",
39+
"soroscan.middleware.GracefulShutdownMiddleware",
3940
"soroscan.middleware.RequestBodySizeMiddleware",
4041
"soroscan.middleware.MaintenanceModeMiddleware",
4142
"django.middleware.security.SecurityMiddleware",
@@ -148,6 +149,8 @@
148149
CELERY_ACCEPT_CONTENT = ["json"]
149150
CELERY_TASK_SERIALIZER = "json"
150151
CELERY_RESULT_SERIALIZER = "json"
152+
SHUTDOWN_TIMEOUT_SECONDS = 30
153+
CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT = 30
151154
CELERY_TIMEZONE = TIME_ZONE
152155

153156
# Stellar / Soroban Configuration

0 commit comments

Comments
 (0)