T-05: Remove app.state mutation from _build_app_context

Move session cache initialization from per-request _build_app_context to
startup lifespan handler. The session cache type is now decided once at app
startup based on settings, making _build_app_context pure (read-only).

Changes:
- Move cache initialization logic to new _update_session_cache() in main.py
- Call _update_session_cache() during lifespan startup to initialize cache
- Remove three if/elif/elif branches mutating state.session_cache from _build_app_context
- Add cache swap logic to set_runtime_settings() in runtime_state.py to handle
  runtime settings changes (e.g., setup wizard updates)
- Keep app.state.session_cache initialization in create_app() for test compatibility

This ensures:
- _build_app_context is pure and doesn't mutate app state on each request
- Session cache configuration decisions are centralized at startup
- Settings changes during runtime (via setup wizard) also trigger cache swap
- Cache initialization logic is isolated in one place

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 18:23:08 +02:00
parent d467190eb1
commit e57d19fd76
3 changed files with 41 additions and 10 deletions

View File

@@ -28,13 +28,13 @@ from app.repositories.protocols import (
GeoCacheRepository,
HistoryArchiveRepository,
ImportLogRepository,
SettingsRepository,
SessionRepository,
SettingsRepository,
)
from app.services.geo_cache import GeoCache
from app.utils.constants import SESSION_COOKIE_NAME
from app.utils.runtime_state import RuntimeState
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache, SessionCache
from app.utils.session_cache import NoOpSessionCache, SessionCache
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -91,13 +91,6 @@ def _build_app_context(request: Request) -> ApplicationContext:
session_cache = getattr(state, "session_cache", None)
if session_cache is None:
session_cache = NoOpSessionCache()
state.session_cache = session_cache
elif _session_cache_enabled(state.settings) and isinstance(session_cache, NoOpSessionCache):
session_cache = InMemorySessionCache()
state.session_cache = session_cache
elif not _session_cache_enabled(state.settings) and not isinstance(session_cache, NoOpSessionCache):
session_cache = NoOpSessionCache()
state.session_cache = session_cache
return ApplicationContext(
settings=state.settings,

View File

@@ -117,6 +117,23 @@ def _configure_logging(log_level: str) -> None:
# ---------------------------------------------------------------------------
def _update_session_cache(app: FastAPI, settings: Settings) -> None:
"""Update the session cache backend based on settings.
Replaces the current cache with InMemorySessionCache or NoOpSessionCache
depending on whether session caching is enabled and configured with a
positive TTL.
Args:
app: The :class:`fastapi.FastAPI` instance.
settings: The effective application settings.
"""
cache_enabled = settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
app.state.session_cache = (
InMemorySessionCache() if cache_enabled else NoOpSessionCache()
)
@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Manage the lifetime of all shared application resources.
@@ -137,6 +154,11 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.http_session = http_session
app.state.scheduler = scheduler
# Ensure session cache is initialized based on effective settings.
# This cache is process-local and not cluster-safe. In multi-worker
# deployments, it should be replaced with a shared backend.
_update_session_cache(app, settings)
log.info("bangui_started")
try:

View File

@@ -24,6 +24,7 @@ from starlette.datastructures import State
from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache
if TYPE_CHECKING: # pragma: no cover
from app.config import Settings
@@ -118,10 +119,25 @@ def get_effective_settings(app: Any) -> Settings:
def set_runtime_settings(app: Any, settings: Settings) -> None:
"""Store the resolved runtime settings separately from bootstrap config."""
"""Store the resolved runtime settings separately from bootstrap config.
Also updates the session cache backend if the session cache configuration
has changed, replacing it with InMemorySessionCache or NoOpSessionCache
as appropriate.
Args:
app: The FastAPI application instance.
settings: The new effective settings.
"""
runtime_state = get_runtime_state(app)
runtime_state.runtime_settings = settings
# Update session cache if settings changed
cache_enabled = settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0
new_cache = InMemorySessionCache() if cache_enabled else NoOpSessionCache()
app.state.session_cache = new_cache
log.debug("session_cache_updated", cache_type=type(new_cache).__name__)
def update_app_settings(app: Any, **overrides: Any) -> None:
"""Update the current effective settings immutably."""