Skip to content

Commit 2639215

Browse files
committed
feat(dirty): add TTIN/TTOU signal support for dynamic worker scaling
Add support for SIGTTIN and SIGTTOU signals to the dirty arbiter, allowing dynamic scaling of dirty workers at runtime without restarting gunicorn. Changes: - Add TTIN/TTOU to DirtyArbiter.SIGNALS - Add num_workers instance variable for dynamic count - Add _get_minimum_workers() to enforce app worker constraints - Add signal handlers for TTIN (increase) and TTOU (decrease) - Update manage_workers() to use dynamic count - Add documentation for dynamic scaling - Add unit tests for signal handling - Add Docker integration tests The minimum worker constraint ensures TTOU cannot reduce workers below what apps require (e.g., if an app has workers=3, minimum is 3). Closes #3489
1 parent ac00c86 commit 2639215

9 files changed

Lines changed: 645 additions & 2 deletions

File tree

docs/content/dirty.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,9 +912,40 @@ Dirty Arbiters integrate with the main arbiter's signal handling. Signals are fo
912912
| `SIGQUIT` | Immediate exit via `sys.exit(0)` | Killed immediately | Fast shutdown, no cleanup |
913913
| `SIGHUP` | Kills all workers, spawns new ones | Exits immediately | Hot reload of workers |
914914
| `SIGUSR1` | Reopens log files, forwards to workers | Reopens log files | Log rotation support |
915+
| `SIGTTIN` | Increases worker count by 1 | N/A | Dynamic scaling up |
916+
| `SIGTTOU` | Decreases worker count by 1 | N/A | Dynamic scaling down |
915917
| `SIGCHLD` | Handled by event loop, triggers reap | N/A | Worker death detection |
916918
| `SIGINT` | Same as SIGTERM | Same as SIGTERM | Ctrl-C handling |
917919

920+
### Dynamic Scaling with TTIN/TTOU
921+
922+
You can dynamically scale the number of dirty workers at runtime using signals, without restarting gunicorn:
923+
924+
```bash
925+
# Find the dirty arbiter process
926+
ps aux | grep dirty-arbiter
927+
# Or use the PID file (location depends on your app name)
928+
cat /tmp/gunicorn-dirty-myapp.pid
929+
930+
# Increase dirty workers by 1
931+
kill -TTIN <dirty-arbiter-pid>
932+
933+
# Decrease dirty workers by 1
934+
kill -TTOU <dirty-arbiter-pid>
935+
```
936+
937+
**Minimum Worker Constraint:** The dirty arbiter will not decrease below the minimum number of workers required by your app configurations. For example, if you have an app with `workers = 3`, you cannot scale below 3 dirty workers. When this limit is reached, a warning is logged:
938+
939+
```
940+
WARNING: SIGTTOU: Cannot decrease below 3 workers (required by app specs)
941+
```
942+
943+
**Use Cases:**
944+
945+
- **Burst handling** - Scale up when you anticipate heavy load
946+
- **Cost optimization** - Scale down during low-traffic periods
947+
- **Recovery** - Scale up if workers are busy with long-running tasks
948+
918949
### Forwarded Signals
919950

920951
The main arbiter forwards these signals to the dirty arbiter process:

gunicorn/dirty/arbiter.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class DirtyArbiter:
5757
"""
5858

5959
SIGNALS = [getattr(signal, "SIG%s" % x) for x in
60-
"HUP QUIT INT TERM USR1 USR2 CHLD".split()]
60+
"HUP QUIT INT TERM TTIN TTOU USR1 USR2 CHLD".split()]
6161

6262
# Worker boot error code
6363
WORKER_BOOT_ERROR = 3
@@ -92,6 +92,7 @@ def __init__(self, cfg, log, socket_path=None, pidfile=None):
9292
self._worker_rr_index = 0 # Round-robin index for worker selection
9393
self.worker_age = 0
9494
self.alive = True
95+
self.num_workers = self.cfg.dirty_workers # Dynamic count for TTIN/TTOU
9596

9697
self._server = None
9798
self._loop = None
@@ -150,6 +151,23 @@ def _parse_app_specs(self):
150151
# Initialize the app_worker_map for this app
151152
self.app_worker_map[import_path] = set()
152153

154+
def _get_minimum_workers(self):
155+
"""
156+
Calculate minimum number of workers required by app specs.
157+
158+
Returns the maximum worker_count across all apps that have limits.
159+
Apps with worker_count=None don't impose a minimum.
160+
161+
Returns:
162+
int: Minimum workers required (at least 1)
163+
"""
164+
min_required = 1
165+
for spec in self.app_specs.values():
166+
worker_count = spec['worker_count']
167+
if worker_count is not None:
168+
min_required = max(min_required, worker_count)
169+
return min_required
170+
153171
def _get_apps_for_new_worker(self):
154172
"""
155173
Determine which apps a new worker should load.
@@ -255,6 +273,8 @@ def init_signals(self):
255273
signal.signal(signal.SIGHUP, self._signal_handler)
256274
signal.signal(signal.SIGUSR1, self._signal_handler)
257275
signal.signal(signal.SIGCHLD, self._signal_handler)
276+
signal.signal(signal.SIGTTIN, self._signal_handler)
277+
signal.signal(signal.SIGTTOU, self._signal_handler)
258278

259279
def _signal_handler(self, sig, frame):
260280
"""Handle signals."""
@@ -279,6 +299,36 @@ def _signal_handler(self, sig, frame):
279299
)
280300
return
281301

302+
if sig == signal.SIGTTIN:
303+
# Increase number of workers
304+
self.num_workers += 1
305+
self.log.info("SIGTTIN: Increasing dirty workers to %s",
306+
self.num_workers)
307+
if self._loop:
308+
self._loop.call_soon_threadsafe(
309+
lambda: asyncio.create_task(self.manage_workers())
310+
)
311+
return
312+
313+
if sig == signal.SIGTTOU:
314+
# Decrease number of workers (respecting minimum)
315+
min_workers = self._get_minimum_workers()
316+
if self.num_workers <= min_workers:
317+
self.log.warning(
318+
"SIGTTOU: Cannot decrease below %s workers "
319+
"(required by app specs)",
320+
min_workers
321+
)
322+
return
323+
self.num_workers -= 1
324+
self.log.info("SIGTTOU: Decreasing dirty workers to %s",
325+
self.num_workers)
326+
if self._loop:
327+
self._loop.call_soon_threadsafe(
328+
lambda: asyncio.create_task(self.manage_workers())
329+
)
330+
return
331+
282332
# Shutdown signals
283333
self.alive = False
284334
if self._loop:
@@ -717,7 +767,7 @@ async def manage_workers(self):
717767
if not self.alive:
718768
return
719769

720-
num_workers = self.cfg.dirty_workers
770+
num_workers = self.num_workers
721771

722772
# Spawn workers if needed
723773
while self.alive and len(self.workers) < num_workers:
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#
2+
# This file is part of gunicorn released under the MIT license.
3+
# See the NOTICE for more information.
4+
5+
"""Tests for dirty arbiter TTIN/TTOU signal handling."""
6+
7+
import signal
8+
from unittest.mock import Mock
9+
10+
import pytest
11+
12+
13+
class TestDirtyArbiterSignals:
14+
"""Test TTIN/TTOU signal handling in DirtyArbiter."""
15+
16+
@pytest.fixture
17+
def arbiter(self, tmp_path):
18+
"""Create a DirtyArbiter for testing."""
19+
from gunicorn.dirty.arbiter import DirtyArbiter
20+
21+
cfg = Mock()
22+
cfg.dirty_workers = 2
23+
cfg.dirty_apps = []
24+
cfg.dirty_timeout = 30
25+
cfg.dirty_graceful_timeout = 30
26+
cfg.on_dirty_starting = Mock()
27+
log = Mock()
28+
29+
arbiter = DirtyArbiter(cfg, log, socket_path=str(tmp_path / "test.sock"))
30+
return arbiter
31+
32+
def test_initial_num_workers_from_config(self, arbiter):
33+
"""num_workers should be initialized from config."""
34+
assert arbiter.num_workers == 2
35+
36+
def test_ttin_increases_num_workers(self, arbiter):
37+
"""SIGTTIN should increase num_workers by 1."""
38+
assert arbiter.num_workers == 2
39+
arbiter._signal_handler(signal.SIGTTIN, None)
40+
assert arbiter.num_workers == 3
41+
42+
def test_ttin_logs_info(self, arbiter):
43+
"""SIGTTIN should log info about the change."""
44+
arbiter._signal_handler(signal.SIGTTIN, None)
45+
arbiter.log.info.assert_called()
46+
call_args = arbiter.log.info.call_args[0]
47+
assert "SIGTTIN" in call_args[0]
48+
assert "3" in str(call_args)
49+
50+
def test_ttou_decreases_num_workers(self, arbiter):
51+
"""SIGTTOU should decrease num_workers by 1."""
52+
arbiter.num_workers = 3
53+
arbiter._signal_handler(signal.SIGTTOU, None)
54+
assert arbiter.num_workers == 2
55+
56+
def test_ttou_logs_info(self, arbiter):
57+
"""SIGTTOU should log info about the change."""
58+
arbiter.num_workers = 3
59+
arbiter._signal_handler(signal.SIGTTOU, None)
60+
arbiter.log.info.assert_called()
61+
call_args = arbiter.log.info.call_args[0]
62+
assert "SIGTTOU" in call_args[0]
63+
assert "2" in str(call_args)
64+
65+
def test_ttou_respects_minimum_one_worker(self, arbiter):
66+
"""SIGTTOU should not go below 1 worker by default."""
67+
arbiter.num_workers = 1
68+
arbiter._signal_handler(signal.SIGTTOU, None)
69+
assert arbiter.num_workers == 1
70+
71+
def test_ttou_logs_warning_at_minimum(self, arbiter):
72+
"""SIGTTOU should log warning when at minimum."""
73+
arbiter.num_workers = 1
74+
arbiter._signal_handler(signal.SIGTTOU, None)
75+
arbiter.log.warning.assert_called()
76+
call_args = arbiter.log.warning.call_args[0]
77+
assert "Cannot decrease below" in call_args[0]
78+
79+
def test_ttou_respects_app_minimum(self, arbiter):
80+
"""SIGTTOU should not go below app-required minimum."""
81+
# App requires 3 workers
82+
arbiter.app_specs = {
83+
'myapp:HeavyTask': {
84+
'import_path': 'myapp:HeavyTask',
85+
'worker_count': 3,
86+
'original_spec': 'myapp:HeavyTask:3',
87+
}
88+
}
89+
arbiter.num_workers = 3
90+
91+
# Should not decrease below 3
92+
arbiter._signal_handler(signal.SIGTTOU, None)
93+
assert arbiter.num_workers == 3
94+
arbiter.log.warning.assert_called()
95+
96+
def test_ttou_with_unlimited_app(self, arbiter):
97+
"""Apps with worker_count=None should not impose minimum."""
98+
arbiter.app_specs = {
99+
'myapp:UnlimitedTask': {
100+
'import_path': 'myapp:UnlimitedTask',
101+
'worker_count': None,
102+
'original_spec': 'myapp:UnlimitedTask',
103+
}
104+
}
105+
arbiter.num_workers = 2
106+
107+
# Should decrease to 1 (default minimum)
108+
arbiter._signal_handler(signal.SIGTTOU, None)
109+
assert arbiter.num_workers == 1
110+
111+
def test_multiple_ttin_signals(self, arbiter):
112+
"""Multiple TTIN signals should keep incrementing."""
113+
assert arbiter.num_workers == 2
114+
arbiter._signal_handler(signal.SIGTTIN, None)
115+
arbiter._signal_handler(signal.SIGTTIN, None)
116+
arbiter._signal_handler(signal.SIGTTIN, None)
117+
assert arbiter.num_workers == 5
118+
119+
def test_multiple_ttou_signals(self, arbiter):
120+
"""Multiple TTOU signals should decrement until minimum."""
121+
arbiter.num_workers = 5
122+
arbiter._signal_handler(signal.SIGTTOU, None)
123+
arbiter._signal_handler(signal.SIGTTOU, None)
124+
arbiter._signal_handler(signal.SIGTTOU, None)
125+
arbiter._signal_handler(signal.SIGTTOU, None)
126+
# Should stop at 1
127+
assert arbiter.num_workers == 1
128+
129+
130+
class TestGetMinimumWorkers:
131+
"""Test _get_minimum_workers calculation."""
132+
133+
@pytest.fixture
134+
def arbiter(self, tmp_path):
135+
"""Create a DirtyArbiter for testing."""
136+
from gunicorn.dirty.arbiter import DirtyArbiter
137+
138+
cfg = Mock()
139+
cfg.dirty_workers = 2
140+
cfg.dirty_apps = []
141+
cfg.dirty_timeout = 30
142+
cfg.dirty_graceful_timeout = 30
143+
cfg.on_dirty_starting = Mock()
144+
log = Mock()
145+
146+
arbiter = DirtyArbiter(cfg, log, socket_path=str(tmp_path / "test.sock"))
147+
return arbiter
148+
149+
def test_minimum_workers_no_apps(self, arbiter):
150+
"""With no apps, minimum should be 1."""
151+
arbiter.app_specs = {}
152+
assert arbiter._get_minimum_workers() == 1
153+
154+
def test_minimum_workers_single_app_with_limit(self, arbiter):
155+
"""Single app with worker_count should set minimum."""
156+
arbiter.app_specs = {
157+
'app:Task': {
158+
'import_path': 'app:Task',
159+
'worker_count': 3,
160+
'original_spec': 'app:Task:3',
161+
}
162+
}
163+
assert arbiter._get_minimum_workers() == 3
164+
165+
def test_minimum_workers_single_app_unlimited(self, arbiter):
166+
"""Single app with worker_count=None should use default minimum."""
167+
arbiter.app_specs = {
168+
'app:Task': {
169+
'import_path': 'app:Task',
170+
'worker_count': None,
171+
'original_spec': 'app:Task',
172+
}
173+
}
174+
assert arbiter._get_minimum_workers() == 1
175+
176+
def test_minimum_workers_multiple_apps_with_limits(self, arbiter):
177+
"""Multiple apps should use the maximum worker_count."""
178+
arbiter.app_specs = {
179+
'app1:Task1': {
180+
'import_path': 'app1:Task1',
181+
'worker_count': 2,
182+
'original_spec': 'app1:Task1:2',
183+
},
184+
'app2:Task2': {
185+
'import_path': 'app2:Task2',
186+
'worker_count': 4,
187+
'original_spec': 'app2:Task2:4',
188+
},
189+
'app3:Task3': {
190+
'import_path': 'app3:Task3',
191+
'worker_count': 3,
192+
'original_spec': 'app3:Task3:3',
193+
},
194+
}
195+
# Maximum of (2, 4, 3) = 4
196+
assert arbiter._get_minimum_workers() == 4
197+
198+
def test_minimum_workers_mixed_limited_and_unlimited(self, arbiter):
199+
"""Mixed apps should use max of limited apps only."""
200+
arbiter.app_specs = {
201+
'app1:Task1': {
202+
'import_path': 'app1:Task1',
203+
'worker_count': 2,
204+
'original_spec': 'app1:Task1:2',
205+
},
206+
'app2:Task2': {
207+
'import_path': 'app2:Task2',
208+
'worker_count': None,
209+
'original_spec': 'app2:Task2',
210+
},
211+
'app3:Task3': {
212+
'import_path': 'app3:Task3',
213+
'worker_count': 4,
214+
'original_spec': 'app3:Task3:4',
215+
},
216+
}
217+
# Maximum of (2, 4) = 4, None is ignored
218+
assert arbiter._get_minimum_workers() == 4
219+
220+
def test_minimum_workers_all_unlimited(self, arbiter):
221+
"""All unlimited apps should use default minimum."""
222+
arbiter.app_specs = {
223+
'app1:Task1': {
224+
'import_path': 'app1:Task1',
225+
'worker_count': None,
226+
'original_spec': 'app1:Task1',
227+
},
228+
'app2:Task2': {
229+
'import_path': 'app2:Task2',
230+
'worker_count': None,
231+
'original_spec': 'app2:Task2',
232+
},
233+
}
234+
assert arbiter._get_minimum_workers() == 1
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM python:3.12-slim
2+
3+
RUN apt-get update && apt-get install -y --no-install-recommends \
4+
curl procps \
5+
&& rm -rf /var/lib/apt/lists/*
6+
7+
WORKDIR /app
8+
9+
# Install gunicorn from source
10+
COPY . /gunicorn-src/
11+
RUN pip install --no-cache-dir /gunicorn-src/
12+
13+
# Copy test app
14+
COPY tests/docker/dirty_ttin_ttou/app.py /app/
15+
COPY tests/docker/dirty_ttin_ttou/gunicorn_conf.py /app/
16+
17+
CMD ["gunicorn", "-c", "gunicorn_conf.py", "app:app"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#
2+
# This file is part of gunicorn released under the MIT license.
3+
# See the NOTICE for more information.
4+
5+
"""Docker integration tests for dirty arbiter TTIN/TTOU signals."""

0 commit comments

Comments
 (0)