feat: enforce single-worker at startup

Fail with RuntimeError when WEB_CONCURRENCY or BANGUI_WORKERS > 1.

In-memory session cache, rate-limit windows, and runtime state are
process-local. Multi-worker silently causes stale limits, ghost sessions,
inconsistent status.

Skipped when TESTING=1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-03 20:33:23 +02:00
parent e1a6491ac2
commit ae9313568e
5 changed files with 919 additions and 10 deletions

View File

@@ -12,6 +12,7 @@ on ``app.state`` throughout the request lifecycle.
from __future__ import annotations
import logging
import os
import re
import sys
from contextlib import asynccontextmanager
@@ -924,6 +925,62 @@ class SetupRedirectMiddleware(BaseHTTPMiddleware):
return await call_next(request)
def _enforce_single_worker() -> None:
"""Fail loudly if multi-worker deployment is detected.
Checks both ``WEB_CONCURRENCY`` (set by gunicorn / many-Rack frameworks)
and the explicit ``BANGUI_WORKERS`` env var. Uvicorn --workers flag also
sets WEB_CONCURRENCY in newer versions.
Skipping is intentional for test mode (TESTING env var set).
Raises:
RuntimeError: If worker count > 1 is detected.
"""
# Check WEB_CONCURRENCY (gunicorn, uvicorn --workers in recent versions)
web_concurrency = os.environ.get("WEB_CONCURRENCY")
if web_concurrency is not None:
try:
workers = int(web_concurrency)
if workers > 1:
raise RuntimeError(
"BanGUI cannot run with multiple workers.\n"
f"WEB_CONCURRENCY is set to {workers}. Expected 1.\n"
"\n"
"Why: in-memory session cache, rate-limit windows, and runtime "
"state are process-local. Multiple workers cause stale rate "
"limits, ghost sessions, and inconsistent server status.\n"
"\n"
"Fix: run with a single worker process. Use container "
"orchestration for horizontal scaling.\n"
"\n"
"See Docs/Deployment.md § Single-Worker Requirement."
)
except ValueError as e:
raise RuntimeError(
f"WEB_CONCURRENCY must be an integer, got: {web_concurrency}"
) from e
# Check explicit BANGUI_WORKERS override (discouraged, still enforced)
bangui_workers = os.environ.get("BANGUI_WORKERS")
if bangui_workers is not None:
try:
workers = int(bangui_workers)
if workers > 1:
raise RuntimeError(
"BanGUI cannot run with multiple workers.\n"
f"BANGUI_WORKERS is set to {workers}. Expected 1.\n"
"\n"
"Fix: set BANGUI_WORKERS=1 or remove from environment.\n"
"\n"
"See Docs/Deployment.md § Single-Worker Requirement."
)
except ValueError as e:
raise RuntimeError(
f"BANGUI_WORKERS must be an integer, got: {bangui_workers}"
) from e
# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------
@@ -943,7 +1000,16 @@ def create_app(settings: Settings | None = None) -> FastAPI:
Returns:
A fully configured :class:`fastapi.FastAPI` application ready for use.
Raises:
RuntimeError: If multi-worker configuration is detected (WEB_CONCURRENCY
or --workers > 1), unless TESTING environment variable is set.
"""
# Enforce single-worker constraint before anything else.
# Skip in test mode (TESTING env var set by test framework or explicitly).
if not os.environ.get("TESTING"):
_enforce_single_worker()
resolved_settings: Settings = settings if settings is not None else get_settings()
# Configure API docs based on enable_docs setting.

View File

@@ -30,7 +30,7 @@ SINGLE-WORKER ENFORCEMENT:
2. Database lock: Only one instance can run the scheduler at a time
3. Startup validation: Fails loudly if multi-worker scenario is detected
See Docs/Architekture.md § Deployment Constraints for full details.
See Docs/Deployment.md § Single-Worker Requirement for full details.
MULTI-WORKER SOLUTION (Future):
To deploy BanGUI with multiple workers in the future (e.g., via gunicorn -w 4):