Add multi-worker detection for APScheduler safety

- Add _check_single_worker_mode() to startup.py that detects and rejects
  multi-worker configurations, raising a clear RuntimeError with instructions
- Set BANGUI_WORKERS=1 as default in Dockerfile.backend
- Document single-worker requirement in compose.prod.yml
- Add 'Deployment Constraints' section to Architekture.md explaining why
  single-worker mode is required and detailing future multi-worker support
- Add '9.1 Background Tasks and Scheduler Architecture' section to
  Backend-Development.md documenting task structure and single-worker requirement
- Add comprehensive test suite (test_startup.py) covering all scenarios:
  allows single worker, rejects multi-worker, validates config format,
  and verifies informative error messages

This fix addresses TASK-002 which identified that in-process APScheduler is
unsafe in multi-worker deployments due to each worker creating independent
scheduler instances, causing duplicate background job execution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 11:39:51 +02:00
parent def412797a
commit 825a67f13a
6 changed files with 212 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ in ``app.main`` delegates resource creation and task registration here.
from __future__ import annotations
import os
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING
@@ -32,6 +33,39 @@ if TYPE_CHECKING:
log: structlog.stdlib.BoundLogger = structlog.get_logger()
def _check_single_worker_mode() -> None:
"""Verify that the application is running with a single worker.
APScheduler's AsyncIOScheduler is bound to a single asyncio event loop
and cannot be safely shared across multiple worker processes. If each
worker starts its own scheduler instance, all background jobs execute N
times (where N is the number of workers), resulting in duplicate blocklist
imports, duplicate ban operations, duplicate history writes, and SQLite
lock contention.
This function detects multi-worker configurations and raises a clear
RuntimeError with instructions.
Raises:
RuntimeError: If the app would run with multiple workers.
"""
# Check for explicit worker count env var (convention used in deployment)
workers_env = os.environ.get("BANGUI_WORKERS")
if workers_env is not None:
try:
worker_count = int(workers_env)
if worker_count > 1:
raise RuntimeError(
"BanGUI background scheduler cannot run with multiple workers.\n"
f"BANGUI_WORKERS is set to {worker_count}. Set it to 1 or remove it.\n"
"See Architekture.md § Deployment Constraints for details."
)
except ValueError as e:
raise RuntimeError(
f"BANGUI_WORKERS environment variable must be an integer, got: {workers_env}"
) from e
async def _ensure_database_schema(database_path: str) -> None:
"""Create the configured runtime database if it does not already exist."""
db = await open_db(database_path)
@@ -70,6 +104,8 @@ async def startup_shared_resources(
Returns:
A tuple of ``(http_session, scheduler)``.
"""
_check_single_worker_mode()
db_path: Path = Path(settings.database_path)
await run_blocking(db_path.parent.mkdir, parents=True, exist_ok=True)

View File

@@ -0,0 +1,57 @@
"""Unit tests for application startup and resource initialization."""
import os
from unittest.mock import patch
import pytest
from app.startup import _check_single_worker_mode
def test_check_single_worker_mode_allows_workers_1() -> None:
"""Single-worker mode is allowed and no error is raised."""
with patch.dict(os.environ, {"BANGUI_WORKERS": "1"}):
# Should not raise
_check_single_worker_mode()
def test_check_single_worker_mode_allows_no_env_var() -> None:
"""When BANGUI_WORKERS is not set, no error is raised."""
with patch.dict(os.environ, {}, clear=False):
# Remove BANGUI_WORKERS if it exists
os.environ.pop("BANGUI_WORKERS", None)
# Should not raise
_check_single_worker_mode()
def test_check_single_worker_mode_rejects_workers_2() -> None:
"""Multi-worker mode (N=2) raises a clear RuntimeError."""
with patch.dict(os.environ, {"BANGUI_WORKERS": "2"}), pytest.raises(
RuntimeError, match="cannot run with multiple workers"
):
_check_single_worker_mode()
def test_check_single_worker_mode_rejects_workers_4() -> None:
"""Multi-worker mode (N=4) raises a clear RuntimeError."""
with patch.dict(os.environ, {"BANGUI_WORKERS": "4"}), pytest.raises(
RuntimeError, match="cannot run with multiple workers"
):
_check_single_worker_mode()
def test_check_single_worker_mode_rejects_invalid_workers_value() -> None:
"""Invalid BANGUI_WORKERS value raises a clear RuntimeError."""
with patch.dict(os.environ, {"BANGUI_WORKERS": "not-a-number"}), pytest.raises(
RuntimeError, match="must be an integer"
):
_check_single_worker_mode()
def test_check_single_worker_mode_error_message_is_informative() -> None:
"""Error message includes instructions and reference to documentation."""
with patch.dict(os.environ, {"BANGUI_WORKERS": "2"}), pytest.raises(RuntimeError) as exc_info:
_check_single_worker_mode()
error_message = str(exc_info.value)
assert "multiple workers" in error_message
assert "Architekture.md" in error_message