Benoit Chesneau 709a6ad159
feat(dirty): add stash - global shared state between workers (#3503)
* feat(dirty): add stash - global shared state between workers

Add a simple key-value store (stash) that allows dirty workers to share
state through the arbiter. Tables are stored directly in arbiter memory
for fast access and simplicity.

Features:
- Auto-create tables on first access
- Dict-like interface via stash.table()
- Pattern matching for keys (glob patterns)
- Module-level API: stash.put(), stash.get(), stash.delete(), etc.

Usage:
    from gunicorn.dirty import stash

    stash.put("sessions", "user:1", {"name": "Alice"})
    user = stash.get("sessions", "user:1")

    # Or dict-like
    sessions = stash.table("sessions")
    sessions["user:1"] = {"name": "Alice"}

New files:
- gunicorn/dirty/stash.py - Client API and StashTable class
- Protocol additions for MSG_TYPE_STASH and STASH_OP_* codes

Note: Tables are ephemeral - lost if arbiter restarts.

* test(dirty): add tests for stash protocol and encoding

Test coverage for:
- Stash message creation and encoding
- Protocol constants (MSG_TYPE_STASH, STASH_OP_*)
- Error classes (StashError, StashTableNotFoundError, StashKeyNotFoundError)
- StashTable dict-like interface
- Edge cases: unicode, complex values, special patterns

* example(dirty): add stash usage example and integration tests

- Add SessionApp to dirty_app.py demonstrating stash usage
- Add /session/* endpoints to wsgi_app.py
- Add test_stash_integration.py with Docker tests
- Update docker-compose.yml with stash-test service
- Fix: Set GUNICORN_DIRTY_SOCKET in dirty arbiter for worker access

* docs(dirty): add stash documentation
2026-02-12 21:45:49 +01:00

226 lines
7.8 KiB
Python

#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
Example WSGI Application that uses Dirty Workers
This demonstrates how HTTP workers can call dirty workers
for heavy operations like ML inference.
Run with:
cd examples/dirty_example
gunicorn wsgi_app:app -c gunicorn_conf.py
"""
import json
import os
from urllib.parse import parse_qs
def get_dirty_client():
"""Get the dirty client, with fallback for when dirty workers aren't enabled."""
try:
from gunicorn.dirty import get_dirty_client as _get_dirty_client
return _get_dirty_client()
except Exception as e:
return None
def app(environ, start_response):
"""WSGI application that demonstrates dirty worker integration."""
path = environ.get('PATH_INFO', '/')
method = environ.get('REQUEST_METHOD', 'GET')
# Parse query string
query = parse_qs(environ.get('QUERY_STRING', ''))
# Get dirty client
client = get_dirty_client()
try:
if path == '/':
result = {
"message": "Dirty Workers Demo",
"dirty_enabled": client is not None,
"pid": os.getpid(),
"endpoints": {
"/models": "List loaded models",
"/load?name=MODEL": "Load a model",
"/inference?model=NAME&data=INPUT": "Run inference",
"/unload?name=MODEL": "Unload a model",
"/fibonacci?n=NUMBER": "Compute fibonacci",
"/prime?n=NUMBER": "Check if prime",
"/stats": "Get dirty worker stats",
"/session/login?user_id=ID&name=NAME": "Login user (stash demo)",
"/session/get?user_id=ID": "Get session (stash demo)",
"/session/list": "List all sessions (stash demo)",
"/session/logout?user_id=ID": "Logout user (stash demo)",
"/session/stats": "Get stash stats (stash demo)",
}
}
elif path == '/models':
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:MLApp",
"list_models"
)
elif path == '/load':
name = query.get('name', ['model1'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:MLApp",
"load_model",
name
)
elif path == '/inference':
model = query.get('model', ['default'])[0]
data = query.get('data', ['test input'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:MLApp",
"inference",
model,
data
)
elif path == '/unload':
name = query.get('name', ['model1'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:MLApp",
"unload_model",
name
)
elif path == '/fibonacci':
n = int(query.get('n', ['10'])[0])
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:ComputeApp",
"fibonacci",
n
)
elif path == '/prime':
n = int(query.get('n', ['17'])[0])
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:ComputeApp",
"prime_check",
n
)
elif path == '/stats':
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
ml_stats = client.execute(
"examples.dirty_example.dirty_app:MLApp",
"list_models"
)
compute_stats = client.execute(
"examples.dirty_example.dirty_app:ComputeApp",
"stats"
)
result = {
"ml_app": ml_stats,
"compute_app": compute_stats,
"http_worker_pid": os.getpid(),
}
# =====================================================================
# Session endpoints (stash demo)
# =====================================================================
elif path == '/session/login':
user_id = query.get('user_id', ['1'])[0]
name = query.get('name', ['Anonymous'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"login",
user_id=user_id,
user_data={"name": name}
)
elif path == '/session/get':
user_id = query.get('user_id', ['1'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"get_session",
user_id=user_id
)
elif path == '/session/list':
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"list_sessions"
)
elif path == '/session/logout':
user_id = query.get('user_id', ['1'])[0]
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"logout",
user_id=user_id
)
elif path == '/session/stats':
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"get_stats"
)
elif path == '/session/clear':
if client is None:
result = {"error": "Dirty workers not enabled"}
else:
result = client.execute(
"examples.dirty_example.dirty_app:SessionApp",
"clear_all"
)
else:
start_response('404 Not Found', [('Content-Type', 'application/json')])
return [json.dumps({"error": "Not found"}).encode()]
# Success response
start_response('200 OK', [('Content-Type', 'application/json')])
return [json.dumps(result, indent=2).encode()]
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()]