Benoit Chesneau 1d0df29796 feat(dirty): add class attribute workers support and e2e tests
- Add get_app_workers_attribute() to read workers class attribute
- Update _parse_app_specs() to check class attribute when no config override
- Add Docker-based e2e tests for per-app worker allocation
- Add test apps: HeavyModelApp (workers=2), LightweightApp
- Add unit tests for get_app_workers_attribute function
- Add integration tests for class attribute detection
2026-02-01 03:04:35 +01:00

185 lines
5.6 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
WSGI and Dirty applications for per-app worker allocation testing.
Contains:
- A WSGI app that can make dirty client requests
- A lightweight dirty app (loads on all workers)
- A heavy dirty app (limited to 2 workers via class attribute)
- A config-limited app (limited to 1 worker via config)
"""
import json
import os
from gunicorn.dirty.app import DirtyApp
def application(environ, start_response):
"""
WSGI application that invokes dirty apps and returns worker info.
Routes:
- GET /lightweight/ping - Call LightweightApp.ping()
- GET /heavy/predict/<data> - Call HeavyApp.predict(data)
- GET /config_limited/info - Call ConfigLimitedApp.get_info()
- GET /status - Get overall status
"""
path = environ.get('PATH_INFO', '/')
method = environ.get('REQUEST_METHOD', 'GET')
if method != 'GET':
start_response('405 Method Not Allowed', [('Content-Type', 'text/plain')])
return [b'Method not allowed']
# Import dirty client here to avoid import at module load
from gunicorn.dirty import get_dirty_client
try:
client = get_dirty_client()
if path == '/status':
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps({"status": "ok"}).encode()]
elif path == '/lightweight/ping':
result = client.execute("app:LightweightApp", "ping")
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps(result).encode()]
elif path.startswith('/heavy/predict/'):
data = path.split('/')[-1]
result = client.execute("app:HeavyApp", "predict", data)
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps(result).encode()]
elif path == '/heavy/get_worker_id':
result = client.execute("app:HeavyApp", "get_worker_id")
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps({"worker_id": result}).encode()]
elif path == '/config_limited/info':
result = client.execute("app:ConfigLimitedApp", "get_info")
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps(result).encode()]
elif path == '/config_limited/get_worker_id':
result = client.execute("app:ConfigLimitedApp", "get_worker_id")
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps({"worker_id": result}).encode()]
elif path == '/lightweight/get_worker_id':
result = client.execute("app:LightweightApp", "get_worker_id")
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps({"worker_id": result}).encode()]
else:
start_response('404 Not Found', [('Content-Type', 'text/plain')])
return [b'Not found']
except Exception as e:
start_response('500 Internal Server Error', [('Content-Type', 'application/json')])
return [json.dumps({"error": str(e), "type": type(e).__name__}).encode()]
class LightweightApp(DirtyApp):
"""
A lightweight app that should load on ALL dirty workers.
workers=None (default) means all workers load this app.
"""
def __init__(self):
self.initialized = False
self.worker_id = None
self.call_count = 0
def init(self):
self.initialized = True
self.worker_id = os.getpid()
def ping(self):
"""Simple ping action."""
self.call_count += 1
return {
"pong": True,
"worker_id": self.worker_id,
"call_count": self.call_count,
}
def get_worker_id(self):
"""Return the worker ID that loaded this app."""
return self.worker_id
def close(self):
pass
class HeavyApp(DirtyApp):
"""
A heavy app that uses the workers class attribute to limit allocation.
workers=2 means only 2 dirty workers will load this app.
This simulates a large ML model that shouldn't be replicated everywhere.
"""
workers = 2 # Only 2 workers should load this app
def __init__(self):
self.initialized = False
self.worker_id = None
self.model_data = None
def init(self):
self.initialized = True
self.worker_id = os.getpid()
# Simulate loading a heavy model
self.model_data = {"loaded": True, "worker": self.worker_id}
def predict(self, data):
"""Simulate model prediction."""
return {
"prediction": f"result_for_{data}",
"worker_id": self.worker_id,
}
def get_worker_id(self):
"""Return the worker ID that loaded this app."""
return self.worker_id
def close(self):
self.model_data = None
class ConfigLimitedApp(DirtyApp):
"""
An app whose worker limit is specified in config (not class attribute).
The config will specify this app as "app:ConfigLimitedApp:1" to limit
it to a single worker.
"""
def __init__(self):
self.initialized = False
self.worker_id = None
def init(self):
self.initialized = True
self.worker_id = os.getpid()
def get_info(self):
"""Get app info."""
return {
"app": "ConfigLimitedApp",
"worker_id": self.worker_id,
}
def get_worker_id(self):
"""Return the worker ID that loaded this app."""
return self.worker_id
def close(self):
pass