From d13efd4e599288f2cae0ad86e636a2a6a9e2e927 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sun, 24 May 2026 22:04:58 +0200 Subject: [PATCH] feat: graceful shutdown and WAL cleanup - Add stop_grace_period to backend container for graceful shutdown - Document WAL mode rationale and orphaned file cleanup in db.py - Handle database close errors gracefully in lifespan - Clean up orphaned WAL files during startup before opening DB - Reorder imports and fix formatting in startup.py --- Docker/compose.prod.yml | 1 + backend/app/db.py | 13 ++++++++++++- backend/app/main.py | 7 ++++++- backend/app/startup.py | 17 +++++++++-------- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Docker/compose.prod.yml b/Docker/compose.prod.yml index dc91cad..7191970 100644 --- a/Docker/compose.prod.yml +++ b/Docker/compose.prod.yml @@ -48,6 +48,7 @@ services: target: runtime container_name: bangui-backend restart: unless-stopped + stop_grace_period: 30s # Give lifespan 30s to complete before SIGKILL depends_on: fail2ban: condition: service_healthy diff --git a/backend/app/db.py b/backend/app/db.py index 85d7567..71ff508 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -274,7 +274,18 @@ CREATE INDEX IF NOT EXISTS idx_import_log_source_id_desc async def _configure_connection(db: aiosqlite.Connection) -> None: - """Apply hardening pragmas to a newly-opened SQLite connection.""" + """Apply hardening pragmas to a newly-opened SQLite connection. + + WAL mode is intentionally kept despite the risk of orphaned ``.wal``/``.shm`` + files after unclean shutdowns. The benefits for concurrent readers + (readers do not block writers) outweigh the cleanup overhead, especially + under load. BanGUI runs as a single worker, but multiple concurrent HTTP + requests can still issue overlapping reads; DELETE mode would serialize + those reads behind any write, degrading API performance. + + Orphaned files are handled by :func:`_cleanup_wal_files`, which is called + during startup before the database is opened. + """ await db.execute("PRAGMA journal_mode=WAL;") await db.execute("PRAGMA foreign_keys=ON;") await db.execute("PRAGMA busy_timeout=5000;") diff --git a/backend/app/main.py b/backend/app/main.py index c0a0ed6..fe5ba37 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -318,7 +318,12 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: log.error("scheduler_lock_release_failed", error=str(e)) # 6. Close the database connection. - await startup_db.close() + try: + await startup_db.close() + log.debug("database_connection_closed") + except Exception as exc: + log.error("database_connection_close_failed", error=str(exc)) + log.info("bangui_shut_down") diff --git a/backend/app/startup.py b/backend/app/startup.py index 1e97730..bf65314 100644 --- a/backend/app/startup.py +++ b/backend/app/startup.py @@ -26,10 +26,9 @@ from pathlib import Path from typing import TYPE_CHECKING, Any import aiohttp -from app.utils.logging_compat import get_logger from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] -from app.db import init_db, open_db +from app.db import _cleanup_wal_files, init_db, open_db from app.services import setup_service from app.services.dns_validated_connector import create_dns_validated_socket_factory from app.services.geo_cache import GeoCache @@ -48,6 +47,7 @@ from app.tasks import ( from app.utils.async_utils import run_blocking from app.utils.fail2ban_db_utils import ensure_fail2ban_indexes from app.utils.jail_config import ensure_jail_configs +from app.utils.logging_compat import get_logger from app.utils.runtime_state import set_runtime_settings from app.utils.scheduler_lock import ( acquire_scheduler_lock, @@ -98,9 +98,7 @@ def _check_single_worker_mode() -> None: "See Docs/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 + 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: @@ -333,6 +331,11 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any: log.debug("database_directory_ensured", directory=str(db_path.parent)) + # Clean up orphaned WAL files from previous unclean shutdowns before + # opening the database. This prevents stale .wal/.shm files from + # interfering with startup or triggering misleading warnings. + await _cleanup_wal_files(settings.database_path) + original_db_path = db_path.resolve() startup_db = await open_db(settings.database_path) @@ -357,9 +360,7 @@ async def _stage_init_database(app: FastAPI, settings: Settings) -> Any: if f2b_db_path: await run_blocking(ensure_fail2ban_indexes, f2b_db_path) - persisted_runtime_settings = ( - await setup_service.get_persisted_runtime_settings(runtime_db) - ) + persisted_runtime_settings = await setup_service.get_persisted_runtime_settings(runtime_db) finally: await runtime_db.close()