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:
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -12,7 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from app.config import Settings
|
||||
from app.db import init_db
|
||||
from app.exceptions import ConfigValidationError, ConfigWriteError, JailNotFoundError
|
||||
from app.main import CORSMiddleware, _lifespan, create_app
|
||||
from app.main import CORSMiddleware, _enforce_single_worker, _lifespan, create_app
|
||||
from app.services import setup_service
|
||||
|
||||
|
||||
@@ -536,3 +536,89 @@ async def test_concurrent_requests_use_request_scoped_db_connections(tmp_path: P
|
||||
assert len({id(connection) for connection in connections}) == 5
|
||||
assert all(response.status_code == 200 for response in responses)
|
||||
assert all(connection.close.await_count == 1 for connection in connections)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-worker enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enforce_single_worker_allows_no_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No error raised when WEB_CONCURRENCY and BANGUI_WORKERS are not set."""
|
||||
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
|
||||
monkeypatch.delenv("BANGUI_WORKERS", raising=False)
|
||||
# Should not raise
|
||||
_enforce_single_worker()
|
||||
|
||||
|
||||
def test_enforce_single_worker_allows_workers_1(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""WEB_CONCURRENCY=1 and BANGUI_WORKERS=1 are both allowed."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "1")
|
||||
monkeypatch.setenv("BANGUI_WORKERS", "1")
|
||||
_enforce_single_worker() # Should not raise
|
||||
|
||||
|
||||
def test_enforce_single_worker_rejects_web_concurrency_2(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""WEB_CONCURRENCY=2 raises RuntimeError."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "2")
|
||||
monkeypatch.delenv("BANGUI_WORKERS", raising=False)
|
||||
with pytest.raises(RuntimeError, match="WEB_CONCURRENCY"):
|
||||
_enforce_single_worker()
|
||||
|
||||
|
||||
def test_enforce_single_worker_rejects_web_concurrency_4(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""WEB_CONCURRENCY=4 raises RuntimeError."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "4")
|
||||
monkeypatch.delenv("BANGUI_WORKERS", raising=False)
|
||||
with pytest.raises(RuntimeError, match="WEB_CONCURRENCY"):
|
||||
_enforce_single_worker()
|
||||
|
||||
|
||||
def test_enforce_single_worker_rejects_bangui_workers_2(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""BANGUI_WORKERS=2 raises RuntimeError."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "1") # WEB_CONCURRENCY=1 should pass
|
||||
monkeypatch.setenv("BANGUI_WORKERS", "2") # but BANGUI_WORKERS=2 should fail
|
||||
with pytest.raises(RuntimeError, match="BANGUI_WORKERS"):
|
||||
_enforce_single_worker()
|
||||
|
||||
|
||||
def test_enforce_single_worker_rejects_invalid_web_concurrency(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Non-integer WEB_CONCURRENCY raises RuntimeError."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "not-a-number")
|
||||
with pytest.raises(RuntimeError, match="WEB_CONCURRENCY must be an integer"):
|
||||
_enforce_single_worker()
|
||||
|
||||
|
||||
def test_enforce_single_worker_error_message_mentions_docs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Error message references Docs/Deployment.md."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "2")
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
_enforce_single_worker()
|
||||
assert "Deployment.md" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_create_app_raises_when_web_concurrency_gt_1(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""create_app() raises RuntimeError when WEB_CONCURRENCY > 1 (no TESTING set)."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "2")
|
||||
with pytest.raises(RuntimeError, match="WEB_CONCURRENCY"):
|
||||
create_app(settings=None) # settings=None triggers get_settings() which loads env vars
|
||||
|
||||
|
||||
def test_create_app_skips_enforcement_when_testing_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""create_app() does NOT raise when TESTING env var is set, even with workers > 1."""
|
||||
monkeypatch.setenv("WEB_CONCURRENCY", "4")
|
||||
monkeypatch.setenv("BANGUI_WORKERS", "4")
|
||||
monkeypatch.setenv("TESTING", "1")
|
||||
# Pass explicit settings to bypass get_settings() env loading.
|
||||
settings = Settings(
|
||||
database_path="/tmp/test.db",
|
||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||
fail2ban_config_dir="/tmp/fail2ban",
|
||||
session_secret="test-secret-key-do-not-use-in-production",
|
||||
session_duration_minutes=60,
|
||||
timezone="UTC",
|
||||
log_level="debug",
|
||||
)
|
||||
# Should not raise
|
||||
app = create_app(settings=settings)
|
||||
assert app is not None
|
||||
|
||||
Reference in New Issue
Block a user