Skip to content

Commit 3cba17b

Browse files
authored
Merge pull request #3504 from benoitc/feature/dirty-ttin-ttou
feat(dirty): add TTIN/TTOU signal support for dynamic worker scaling
2 parents 709a6ad + 0077b05 commit 3cba17b

File tree

15 files changed

+734
-11
lines changed

15 files changed

+734
-11
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ gunicorn myapp:app --worker-class asgi
3434
## Features
3535

3636
- WSGI support for Django, Flask, Pyramid, and any WSGI framework
37-
- **ASGI support** (beta) for FastAPI, Starlette, Quart
37+
- **ASGI support** for FastAPI, Starlette, Quart
3838
- **HTTP/2 support** (beta) with multiplexed streams
39-
- **Dirty Arbiters** for heavy workloads (ML models, long-running tasks)
39+
- **Dirty Arbiters** (beta) for heavy workloads (ML models, long-running tasks)
4040
- uWSGI binary protocol for nginx integration
4141
- Multiple worker types: sync, gthread, gevent, eventlet, asgi
4242
- Graceful worker process management

docs/content/2026-news.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
11
<span id="news-2026"></span>
22
# Changelog - 2026
33

4+
## 25.1.0 - 2026-02-12
5+
6+
### New Features
7+
8+
- **Dirty Stash**: Add global shared state between workers via `dirty.stash`
9+
([PR #3503](https://github.com/benoitc/gunicorn/pull/3503))
10+
- In-memory key-value store accessible by all workers
11+
- Supports get, set, delete, clear, keys, and has operations
12+
- Useful for sharing state like feature flags, rate limits, or cached data
13+
14+
- **Dirty Binary Protocol**: Implement efficient binary protocol for dirty arbiter IPC
15+
using TLV (Type-Length-Value) encoding
16+
([PR #3500](https://github.com/benoitc/gunicorn/pull/3500))
17+
- More efficient than JSON for binary data
18+
- Supports all Python types: str, bytes, int, float, bool, None, list, dict
19+
- Better performance for large payloads
20+
21+
- **Dirty TTIN/TTOU Signals**: Add dynamic worker scaling for dirty arbiters
22+
([PR #3504](https://github.com/benoitc/gunicorn/pull/3504))
23+
- Send SIGTTIN to increase dirty workers
24+
- Send SIGTTOU to decrease dirty workers
25+
- Respects minimum worker constraints from app configurations
26+
27+
### Changes
28+
29+
- **ASGI Worker**: Promoted from beta to stable
30+
- **Dirty Arbiters**: Now marked as beta feature
31+
32+
### Documentation
33+
34+
- Fix Markdown formatting in /configure documentation
35+
36+
---
37+
38+
## 25.0.3 - 2026-02-07
39+
40+
### Bug Fixes
41+
42+
- Fix RuntimeError when StopIteration is raised inside ASGI response body
43+
coroutine (PEP 479 compliance)
44+
45+
- Fix deprecation warning for passing maxsplit as positional argument in
46+
`re.split()` (Python 3.13+)
47+
48+
---
49+
450
## 25.0.2 - 2026-02-06
551

652
### Bug Fixes

docs/content/asgi.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
# ASGI Worker
22

3-
!!! warning "Beta Feature"
4-
The ASGI worker is a beta feature introduced in Gunicorn 24.0.0. While it has been tested,
5-
the API and behavior may change in future releases. Please report any issues on
6-
[GitHub](https://github.com/benoitc/gunicorn/issues).
7-
83
Gunicorn includes a native ASGI worker that enables running async Python web frameworks
94
like FastAPI, Starlette, and Quart without external dependencies like Uvicorn.
105

docs/content/dirty.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ menu:
77

88
# Dirty Arbiters
99

10+
!!! warning "Beta Feature"
11+
Dirty Arbiters is a beta feature introduced in Gunicorn 25.0.0. While it has been tested,
12+
the API and behavior may change in future releases. Please report any issues on
13+
[GitHub](https://github.com/benoitc/gunicorn/issues).
14+
1015
Dirty Arbiters provide a separate process pool for executing long-running, blocking operations (AI model loading, heavy computation) without blocking HTTP workers. This feature is inspired by Erlang's dirty schedulers.
1116

1217
## Overview
@@ -907,9 +912,40 @@ Dirty Arbiters integrate with the main arbiter's signal handling. Signals are fo
907912
| `SIGQUIT` | Immediate exit via `sys.exit(0)` | Killed immediately | Fast shutdown, no cleanup |
908913
| `SIGHUP` | Kills all workers, spawns new ones | Exits immediately | Hot reload of workers |
909914
| `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 |
910917
| `SIGCHLD` | Handled by event loop, triggers reap | N/A | Worker death detection |
911918
| `SIGINT` | Same as SIGTERM | Same as SIGTERM | Ctrl-C handling |
912919

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+
913949
### Forwarded Signals
914950

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

docs/content/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ title: Gunicorn - Python WSGI HTTP Server
8383
<p>Multiple threads per worker. Balance concurrency and simplicity.</p>
8484
</a>
8585
<a class="worker" href="asgi/">
86-
<h3>ASGI <span class="badge">Beta</span></h3>
86+
<h3>ASGI</h3>
8787
<p>Native asyncio for FastAPI, Starlette, and async frameworks.</p>
8888
</a>
8989
</div>

docs/content/news.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
<span id="news"></span>
22
# Changelog
33

4+
## 25.1.0 - 2026-02-12
5+
6+
### New Features
7+
8+
- **Dirty Stash**: Add global shared state between workers via `dirty.stash`
9+
([PR #3503](https://github.com/benoitc/gunicorn/pull/3503))
10+
- In-memory key-value store accessible by all workers
11+
- Supports get, set, delete, clear, keys, and has operations
12+
- Useful for sharing state like feature flags, rate limits, or cached data
13+
14+
- **Dirty Binary Protocol**: Implement efficient binary protocol for dirty arbiter IPC
15+
using TLV (Type-Length-Value) encoding
16+
([PR #3500](https://github.com/benoitc/gunicorn/pull/3500))
17+
- More efficient than JSON for binary data
18+
- Supports all Python types: str, bytes, int, float, bool, None, list, dict
19+
- Better performance for large payloads
20+
21+
- **Dirty TTIN/TTOU Signals**: Add dynamic worker scaling for dirty arbiters
22+
([PR #3504](https://github.com/benoitc/gunicorn/pull/3504))
23+
- Send SIGTTIN to increase dirty workers
24+
- Send SIGTTOU to decrease dirty workers
25+
- Respects minimum worker constraints from app configurations
26+
27+
### Changes
28+
29+
- **ASGI Worker**: Promoted from beta to stable
30+
- **Dirty Arbiters**: Now marked as beta feature
31+
32+
### Documentation
33+
34+
- Fix Markdown formatting in /configure documentation
35+
36+
---
37+
438
## 25.0.3 - 2026-02-07
539

640
### Bug Fixes

gunicorn/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# This file is part of gunicorn released under the MIT license.
33
# See the NOTICE for more information.
44

5-
version_info = (25, 0, 3)
5+
version_info = (25, 1, 0)
66
__version__ = ".".join([str(v) for v in version_info])
77
SERVER = "gunicorn"
88
SERVER_SOFTWARE = "%s/%s" % (SERVER, __version__)

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:

0 commit comments

Comments
 (0)