Improve deployment safety and chat reliability
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
Deploy FluentGerman.ai / deploy (push) Successful in 1m25s
This commit is contained in:
+78
-6
@@ -1,5 +1,6 @@
|
||||
"""FluentGerman.ai — FastAPI application entry point."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -7,7 +8,8 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import inspect, select
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
|
||||
from app.auth import hash_password
|
||||
from app.config import get_settings
|
||||
@@ -30,11 +32,68 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("fluentgerman")
|
||||
|
||||
|
||||
# A worker that loses the create_all race has to wait for the winner to finish
|
||||
# creating the remaining tables before it can judge the schema complete.
|
||||
SCHEMA_RETRIES = 10
|
||||
SCHEMA_RETRY_DELAY = 0.5
|
||||
|
||||
|
||||
def _is_table_already_exists(exc: Exception) -> bool:
|
||||
"""True only for the specific DDL error that means another worker won.
|
||||
|
||||
Anything else — bad credentials, missing privileges, an unreachable
|
||||
server — must not be mistaken for a race.
|
||||
"""
|
||||
orig = getattr(exc, "orig", None)
|
||||
args = getattr(orig, "args", ())
|
||||
if args and args[0] == 1050: # MySQL ER_TABLE_EXISTS_ERROR
|
||||
return True
|
||||
return "already exists" in str(orig or exc).lower()
|
||||
|
||||
|
||||
async def _ensure_schema() -> None:
|
||||
"""Create the tables and refuse to continue on a half-built schema.
|
||||
|
||||
An 'already exists' error aborts create_all part-way, so the losing worker
|
||||
may still be missing tables the winner has not created yet. Retry rather
|
||||
than treating that moment as a broken database.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
|
||||
for attempt in range(SCHEMA_RETRIES):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except (IntegrityError, OperationalError, ProgrammingError) as exc:
|
||||
if not _is_table_already_exists(exc):
|
||||
raise
|
||||
logger.warning("Schema creation raced with another worker: %s", exc)
|
||||
|
||||
async with engine.connect() as conn:
|
||||
present = await conn.run_sync(lambda c: set(inspect(c).get_table_names()))
|
||||
|
||||
missing = sorted(set(Base.metadata.tables) - present)
|
||||
if not missing:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Waiting for another worker to finish creating %s (attempt %d/%d)",
|
||||
missing, attempt + 1, SCHEMA_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(SCHEMA_RETRY_DELAY)
|
||||
|
||||
raise RuntimeError(f"Database schema is incomplete — missing tables: {missing}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Create tables and bootstrap admin user on startup."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
"""Create tables and bootstrap admin user on startup.
|
||||
|
||||
uvicorn runs this once per worker and the unit file starts two, so on an
|
||||
empty database both can reach the same check at the same moment. Both
|
||||
steps below are written to tolerate losing that race.
|
||||
"""
|
||||
await _ensure_schema()
|
||||
|
||||
# Bootstrap admin if not exists
|
||||
settings = get_settings()
|
||||
@@ -48,8 +107,21 @@ async def lifespan(app: FastAPI):
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(admin)
|
||||
await db.commit()
|
||||
logger.info("Admin user created: %s", settings.admin_username)
|
||||
try:
|
||||
await db.commit()
|
||||
logger.info("Admin user created: %s", settings.admin_username)
|
||||
except IntegrityError:
|
||||
# Either another worker inserted the same admin, or the
|
||||
# configured username/email is taken by a non-admin account.
|
||||
await db.rollback()
|
||||
result = await db.execute(select(User).where(User.is_admin == True)) # noqa: E712
|
||||
if result.scalar_one_or_none() is None:
|
||||
logger.error(
|
||||
"Cannot create admin '%s': the username or email already "
|
||||
"belongs to a non-admin account.", settings.admin_username,
|
||||
)
|
||||
raise
|
||||
logger.info("Admin user already created by another worker")
|
||||
|
||||
logger.info("FluentGerman.ai started — LLM: %s/%s, Voice: %s",
|
||||
settings.llm_provider, settings.llm_model, settings.voice_mode)
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi.responses import Response
|
||||
from app.auth import require_admin, get_current_user
|
||||
from app.config import get_settings
|
||||
from app.models import User
|
||||
from app.schemas import VoiceConfigOut, VoiceInstructionRequest
|
||||
from app.schemas import SynthesizeRequest, VoiceConfigOut, VoiceInstructionRequest
|
||||
from app.services.llm_service import summarize_instruction
|
||||
from app.services.voice_service import synthesize, transcribe
|
||||
|
||||
@@ -48,12 +48,12 @@ async def transcribe_audio(
|
||||
|
||||
@router.post("/synthesize")
|
||||
async def synthesize_text(
|
||||
text: str,
|
||||
body: SynthesizeRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Convert text to speech audio (API mode only)."""
|
||||
try:
|
||||
audio_bytes = await synthesize(text)
|
||||
audio_bytes = await synthesize(body.text)
|
||||
return Response(content=audio_bytes, media_type="audio/mpeg")
|
||||
except Exception as e:
|
||||
logger.error(f"Synthesis failed: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -88,6 +88,10 @@ class VoiceInstructionRequest(BaseModel):
|
||||
raw_text: str
|
||||
|
||||
|
||||
class SynthesizeRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class VoiceConfigOut(BaseModel):
|
||||
voice_mode: str # "api" | "browser"
|
||||
voice_api_available: bool = False # True if API STT (Whisper) is configured
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""FluentGerman.ai — Startup / lifespan tests.
|
||||
|
||||
The API tests drive the app through httpx's ASGITransport, which does NOT run
|
||||
lifespan events — so table creation and the admin bootstrap in main.lifespan
|
||||
were never exercised. The suite could stay green while the app failed to boot.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import func, inspect as sa_inspect, select
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
|
||||
from app import main
|
||||
from app.config import get_settings
|
||||
from app.database import Base
|
||||
from app.models import User
|
||||
from tests.conftest import test_engine as engine, test_session as session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def startup_app(monkeypatch):
|
||||
"""Point main's module-level engine/session at the test database."""
|
||||
monkeypatch.setattr(main, "engine", engine)
|
||||
monkeypatch.setattr(main, "async_session", session_factory)
|
||||
return main.app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_creates_schema_and_bootstraps_admin(startup_app):
|
||||
"""Starting from an empty database, startup must create tables and an admin."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
async with session_factory() as db:
|
||||
result = await db.execute(select(User).where(User.is_admin.is_(True)))
|
||||
admin = result.scalar_one_or_none()
|
||||
|
||||
assert admin is not None, "startup did not create an admin user"
|
||||
assert admin.username == get_settings().admin_username
|
||||
assert admin.hashed_password != get_settings().admin_password, "password stored in clear"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_is_idempotent(startup_app):
|
||||
"""A restart must not create a second admin."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
for _ in range(2):
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
async with session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.is_admin.is_(True))
|
||||
)
|
||||
assert result.scalar_one() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_survives_concurrent_workers(startup_app):
|
||||
"""deploy/fluentgerman.service starts uvicorn with --workers 2, and each
|
||||
worker runs this lifespan. On an empty database they race on the same
|
||||
check-then-insert; neither worker may fail."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
async def boot():
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
await asyncio.gather(boot(), boot())
|
||||
|
||||
async with session_factory() as db:
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.is_admin.is_(True))
|
||||
)
|
||||
assert result.scalar_one() == 1, "concurrent startup created a duplicate admin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_reraises_real_ddl_failures(startup_app, monkeypatch):
|
||||
"""Only a genuine 'table already exists' race may be swallowed — a
|
||||
permission or connection failure has to stop the worker."""
|
||||
def explode(*args, **kwargs):
|
||||
raise OperationalError(
|
||||
"CREATE TABLE users (...)", {},
|
||||
Exception("(1142, \"CREATE command denied to user 'fluentgerman'\")"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(main.Base.metadata, "create_all", explode)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_refuses_to_start_on_incomplete_schema(startup_app, monkeypatch):
|
||||
"""If some tables are missing, starting anyway would leave the app broken
|
||||
in a way the admin query alone would not notice."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0)
|
||||
monkeypatch.setattr(main.Base.metadata, "create_all", lambda *a, **k: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="schema is incomplete"):
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_waits_out_a_partial_schema_race(startup_app, monkeypatch):
|
||||
"""An 'already exists' error aborts create_all part-way, so the losing
|
||||
worker can briefly see a schema the winner is still finishing. That is a
|
||||
moment to retry, not a reason to refuse to start."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
real_create_all = main.Base.metadata.create_all
|
||||
calls = {"n": 0}
|
||||
|
||||
def racing_create_all(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
# the winner got there first and we bailed out having created nothing
|
||||
raise OperationalError(
|
||||
"CREATE TABLE users (...)", {},
|
||||
Exception("table 'users' already exists"),
|
||||
)
|
||||
return real_create_all(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(main, "SCHEMA_RETRY_DELAY", 0)
|
||||
monkeypatch.setattr(main.Base.metadata, "create_all", racing_create_all)
|
||||
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
|
||||
assert calls["n"] >= 2, "should have retried after the race"
|
||||
|
||||
async with engine.connect() as conn:
|
||||
present = await conn.run_sync(lambda c: set(sa_inspect(c).get_table_names()))
|
||||
assert set(Base.metadata.tables) <= present
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_fails_loudly_when_admin_name_is_taken(startup_app):
|
||||
"""A non-admin already holding the configured username is not a lost race —
|
||||
starting with no administrator at all would be worse than refusing."""
|
||||
async with session_factory() as db:
|
||||
db.add(User(
|
||||
username=get_settings().admin_username,
|
||||
email="someone-else@example.com",
|
||||
hashed_password="x",
|
||||
is_admin=False,
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
async with main.lifespan(startup_app):
|
||||
pass
|
||||
Reference in New Issue
Block a user