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:
@@ -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)
|
||||
|
||||
|
||||
57
backend/tests/test_startup.py
Normal file
57
backend/tests/test_startup.py
Normal 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
|
||||
Reference in New Issue
Block a user