feat: enhance database migration handling and health check endpoint
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m55s

- Updated migration logic in main.py to allow skipping migrations during local development with SKIP_DB_MIGRATE environment variable.
- Improved error handling for migration failures, ensuring the application does not start if migrations are incomplete.
- Added a new health check endpoint (/api/health/ready) to verify database connection and essential tables, aiding in production debugging.
- Enhanced run_migrations.py to support ordered execution of migration files and improved transaction handling.
- Updated requirements.txt to include sqlparse for SQL statement parsing when psql is unavailable.
This commit is contained in:
Lars 2026-04-29 12:29:39 +02:00
parent 159ac8fb71
commit 1f2c8ea0f1
6 changed files with 341 additions and 88 deletions

View File

@ -4,26 +4,38 @@ Shinkan Jinkendo - Main Application Entry Point
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
""" """
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import os import os
import sys
from slowapi import _rate_limit_exceeded_handler from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
# Run database migrations on startup # Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
print("⚠ SKIP_DB_MIGRATE=1 — Migrationen wurden übersprungen (nur für Entwicklung ohne DB)")
else:
try: try:
import run_migrations import run_migrations
run_migrations.main()
rc = run_migrations.main()
if rc != 0:
print(f"✗ Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.")
sys.exit(1)
print("✓ Database migrations completed") print("✓ Database migrations completed")
except SystemExit:
raise
except Exception as e: except Exception as e:
print(f"⚠ Warning: Migration error: {e}") print(f"✗ Migration-Laufzeitfehler: {e}")
print(" Continuing startup - migrations may need manual intervention") sys.exit(1)
from routers.auth import limiter as auth_rate_limiter from routers.auth import limiter as auth_rate_limiter
@ -71,6 +83,63 @@ def health_check():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy", "version": APP_VERSION} return {"status": "healthy", "version": APP_VERSION}
@app.get("/api/health/ready")
def health_ready():
"""
Verbindung + Kern-Tabellen prüfen (ohne Login).
Nutzen bei Prod-Debugging: wenn schema_complete=false, Migrationen nicht vollständig.
"""
from db import get_db, get_cursor
REQUIRED = (
"schema_migrations",
"skills",
"skill_main_categories",
"skill_categories",
"maturity_models",
"sessions",
)
tables: dict = {}
err: Optional[str] = None
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT 1")
for tbl in REQUIRED:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
)
""",
(tbl,),
)
row = cur.fetchone()
tables[tbl] = bool(next(iter(row.values()))) if row else False
migration_count = 0
if tables.get("schema_migrations"):
cur.execute("SELECT COUNT(*)::int FROM schema_migrations")
mig_row = cur.fetchone()
migration_count = (
int(list(mig_row.values())[0]) if mig_row is not None else 0
)
except Exception as e:
err = str(e)
migration_count = 0
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
return {
"status": "ready" if complete else "degraded",
"database": err is None,
"detail": err,
"schema_complete": complete,
"tables": tables,
"schema_migrations_count": migration_count,
}
# Root Endpoint # Root Endpoint
@app.get("/") @app.get("/")
def read_root(): def read_root():

View File

@ -10,3 +10,4 @@ slowapi==0.1.9
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dateutil==2.9.0 python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql)

View File

@ -9,6 +9,7 @@ import json
from typing import Any, Optional from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth from auth import require_auth
@ -16,6 +17,15 @@ from auth import require_auth
router = APIRouter(prefix="/api", tags=["skills"]) router = APIRouter(prefix="/api", tags=["skills"])
def _jsonb_param(val: Any) -> Any:
"""psycopg2 benötigt Json() oder String für JSONB — rohe dict/list sonst ProgrammingError."""
if val is None:
return None
if isinstance(val, (dict, list)):
return Json(val)
return val
def _main_id_for_category(cur, category_id: Optional[int]) -> Optional[int]: def _main_id_for_category(cur, category_id: Optional[int]) -> Optional[int]:
if not category_id: if not category_id:
return None return None
@ -181,7 +191,7 @@ def create_skill(data: dict, session=Depends(require_auth)):
data.get("category"), data.get("category"),
data.get("description"), data.get("description"),
data.get("importance"), data.get("importance"),
data.get("keywords"), _jsonb_param(data.get("keywords")),
data.get("status", "active"), data.get("status", "active"),
cat_id, cat_id,
main_id, main_id,
@ -218,7 +228,6 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
"category", "category",
"description", "description",
"importance", "importance",
"keywords",
"status", "status",
"category_id", "category_id",
"main_category_id", "main_category_id",
@ -227,6 +236,9 @@ def update_skill(skill_id: int, data: dict, session=Depends(require_auth)):
if key in data: if key in data:
sets.append(f"{key} = %s") sets.append(f"{key} = %s")
vals.append(data[key]) vals.append(data[key])
if "keywords" in data:
sets.append("keywords = %s")
vals.append(_jsonb_param(data["keywords"]))
if "focus_areas" in data: if "focus_areas" in data:
fa = data["focus_areas"] fa = data["focus_areas"]
if isinstance(fa, (list, dict)): if isinstance(fa, (list, dict)):
@ -271,7 +283,6 @@ def delete_skill(skill_id: int, session=Depends(require_auth)):
# Delete # Delete
cur.execute("DELETE FROM skills WHERE id = %s", (skill_id,)) cur.execute("DELETE FROM skills WHERE id = %s", (skill_id,))
conn.commit()
return {"ok": True} return {"ok": True}

View File

@ -1,31 +1,57 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Shinkan Jinkendo - Database Migrations Runner Shinkan Jinkendo Datenbank-Migrationen
Runs all SQL migrations in backend/migrations/ directory **Idempotent** über `schema_migrations`: jede numerische Datei `migrations/*.sql` höchstens einmal
Tracks executed migrations in schema_migrations table als erfolgreich eingetragen; bei erneutem Start werden nur noch fehlende Dateien abgearbeitet.
**Reihenfolge:** Alle `NNN_*.sql` nach führender Zahl (001 vor 009 vor 010 ), bei gleicher Zahl
alphabetisch nach Dateinamen nicht bloß String-Sortierung (vermeidet z.B. `10_` vor `9_`).
**Pro Datei eine Transaktion:** Entweder `psql -1 -f` (wie bei psql/pg_dump üblich) oder Fallback
über `sqlparse.split` und einzelne `cursor.execute` in einer DB-Transaktion.
Nach erfolgreicher Ausführung: `INSERT INTO schema_migrations (migration)` (mit `ON CONFLICT DO NOTHING`).
""" """
import os import os
import re
import shutil
import subprocess
import sys import sys
import psycopg2
from psycopg2 import sql
import time import time
from typing import List, Tuple
import psycopg2
import sqlparse
def _db_params():
return {
"host": os.getenv("DB_HOST", "localhost"),
"port": os.getenv("DB_PORT", "5432"),
"dbname": os.getenv("DB_NAME", "shinkan_dev"),
"user": os.getenv("DB_USER", "shinkan_dev"),
"password": os.getenv("DB_PASSWORD", "dev_password"),
}
def get_db_connection(): def get_db_connection():
"""Get database connection with retries""" """PostgreSQL-Verbindung mit Retry (Container-Start)."""
p = _db_params()
max_retries = 30 max_retries = 30
for i in range(max_retries): for i in range(max_retries):
try: try:
conn = psycopg2.connect( conn = psycopg2.connect(
host=os.getenv("DB_HOST", "localhost"), host=p["host"],
port=os.getenv("DB_PORT", "5432"), port=p["port"],
database=os.getenv("DB_NAME", "shinkan_dev"), database=p["dbname"],
user=os.getenv("DB_USER", "shinkan_dev"), user=p["user"],
password=os.getenv("DB_PASSWORD", "dev_password") password=p["password"],
) )
print(f"✓ Connected to database: {os.getenv('DB_NAME')}") conn.autocommit = False
print(f"✓ Connected to database: {p['dbname']}")
return conn return conn
except psycopg2.OperationalError as e: except psycopg2.OperationalError:
if i < max_retries - 1: if i < max_retries - 1:
print(f"Waiting for database... ({i+1}/{max_retries})") print(f"Waiting for database... ({i+1}/{max_retries})")
time.sleep(2) time.sleep(2)
@ -33,116 +59,222 @@ def get_db_connection():
print(f"✗ Failed to connect to database after {max_retries} attempts") print(f"✗ Failed to connect to database after {max_retries} attempts")
raise raise
def init_migrations_table(conn): def init_migrations_table(conn):
"""Create schema_migrations table if not exists"""
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
migration VARCHAR(255) UNIQUE NOT NULL, migration VARCHAR(255) UNIQUE NOT NULL,
executed_at TIMESTAMP DEFAULT NOW() executed_at TIMESTAMP DEFAULT NOW()
) )
""") """
)
conn.commit() conn.commit()
print("Migrations table initialized") print("schema_migrations initialisiert")
def get_executed_migrations(conn):
"""Get list of already executed migrations""" _LEADING_DIGITS = re.compile(r"^(\d+)")
def _migration_sort_key_from_stem(stem: str) -> Tuple[int, str]:
"""Sortierung: numerisches Präfix am Anfang des Stems, dann voller Stem (stabil)."""
m = _LEADING_DIGITS.match(stem)
n = int(m.group(1)) if m else 0
return (n, stem)
def get_migration_files_ordered(migrations_dir: str) -> List[Tuple[str, str]]:
rows = []
for filename in os.listdir(migrations_dir):
if not filename.endswith(".sql"):
continue
if not filename[0].isdigit():
continue
stem = filename[:-4]
rows.append((stem, os.path.join(migrations_dir, filename)))
rows.sort(key=lambda item: _migration_sort_key_from_stem(item[0]))
return rows
def get_executed(conn) -> set:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT migration FROM schema_migrations ORDER BY migration") cur.execute("SELECT migration FROM schema_migrations")
return set(row[0] for row in cur.fetchall()) return set(r[0] for r in cur.fetchall())
def get_pending_migrations(executed):
"""Get list of pending migrations from migrations directory"""
migrations_dir = "/app/migrations"
if not os.path.exists(migrations_dir):
migrations_dir = "backend/migrations" # Local development
all_migrations = [] def get_pending(conn, migrations_dir: str):
for filename in sorted(os.listdir(migrations_dir)): executed = get_executed(conn)
if filename.endswith('.sql') and filename[0].isdigit(): pending = []
migration_name = filename.replace('.sql', '') for name, path in get_migration_files_ordered(migrations_dir):
if migration_name not in executed: if name not in executed:
all_migrations.append((migration_name, os.path.join(migrations_dir, filename))) pending.append((name, path))
return pending
return all_migrations
def run_migration(conn, migration_name, filepath): def _split_statements(sql_text: str) -> List[str]:
"""Run a single migration file""" """Fallback ohne psql — einzelne Statements (sqlparse.split)."""
print(f"Running migration: {migration_name}") stripped = sql_text.strip()
if not stripped:
return []
parts = sqlparse.split(stripped)
return [p.strip() for p in parts if p and p.strip()]
with open(filepath, 'r', encoding='utf-8') as f:
sql_content = f.read()
try: def _run_file_with_psql(filepath: str) -> Tuple[bool, str]:
"""
Ganze Datei wie von psql dokumentiert (-1 eine Transaktion, ON_ERROR_STOP).
Unter Windows oft nicht verfügbar zurück False ohne Fehlertext wenn kein Binary.
"""
psql = shutil.which("psql")
if not psql:
return False, ""
p = _db_params()
env = os.environ.copy()
env["PGPASSWORD"] = str(p["password"])
cmd = [
psql,
"-h",
p["host"],
"-p",
str(p["port"]),
"-U",
p["user"],
"-d",
p["dbname"],
"-v",
"ON_ERROR_STOP=1",
"-1",
"-f",
filepath,
]
proc = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=7200,
)
if proc.returncode != 0:
tail = (
(proc.stderr or "").strip()
+ "\n"
+ (proc.stdout or "").strip()
).strip()
return False, tail[:8000] or f"exit {proc.returncode}"
out = (proc.stdout or "").strip()
return True, out
def _record_migration(conn, migration_name: str) -> None:
with conn.cursor() as cur: with conn.cursor() as cur:
# Execute migration
cur.execute(sql_content)
# Record migration
cur.execute( cur.execute(
"INSERT INTO schema_migrations (migration) VALUES (%s)", """
(migration_name,) INSERT INTO schema_migrations (migration)
VALUES (%s)
ON CONFLICT (migration) DO NOTHING
""",
(migration_name,),
) )
def run_migration(conn, migration_name: str, filepath: str) -> bool:
print(f"Running migration: {migration_name}")
detail_suffix = ""
try:
if shutil.which("psql"):
ok, diag = _run_file_with_psql(filepath)
if not ok:
print(f" ✗ psql fehlgeschlagen:\n{diag or '(kein Output)'}")
conn.rollback()
return False
detail_suffix = "(psql -1)"
else:
try:
with open(filepath, "r", encoding="utf-8") as fh:
body = fh.read()
except OSError as e:
print(f" ✗ kann Datei nicht lesen: {e}")
conn.rollback()
return False
statements = _split_statements(body)
with conn.cursor() as cur:
if not statements:
print(
f" ⚠ keine ausführbaren Statements (leer?) — "
f"Eintrag trotzdem: {migration_name}"
)
else:
for stmt in statements:
cur.execute(stmt)
detail_suffix = f"(psycopg2 + sqlparse, {len(statements)} Statements)"
_record_migration(conn, migration_name)
conn.commit() conn.commit()
print(f"{migration_name} executed successfully") print(f"{migration_name} erfolgreich {detail_suffix}")
return True return True
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
print(f"{migration_name} failed: {e}") print(f"{migration_name}: {e}")
return False return False
def main(): def main():
"""Main migrations runner"""
print("=" * 60) print("=" * 60)
print("Shinkan Jinkendo - Database Migrations") print("Shinkan Jinkendo — Database Migrations")
print("(Warteschlange: nur fehlende *.sql — idempotent)")
print("=" * 60) print("=" * 60)
migrations_dir = "/app/migrations"
if not os.path.isdir(migrations_dir):
migrations_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "migrations")
try: try:
# Connect to database
conn = get_db_connection() conn = get_db_connection()
# Initialize migrations tracking
init_migrations_table(conn) init_migrations_table(conn)
# Get executed and pending migrations pending = get_pending(conn, migrations_dir)
executed = get_executed_migrations(conn)
pending = get_pending_migrations(executed)
if not pending: if not pending:
print("✓ No pending migrations - database is up to date") print("✓ Keine ausstehenden Migrationen — Schema aktuell.")
conn.close()
return 0 return 0
print(f"\nFound {len(pending)} pending migration(s):") print(f"\n{len(pending)} ausstehende Migration(en):")
for name, _ in pending: for n, _ in pending:
print(f" - {name}") print(f" - {n}")
print() print()
# Run pending migrations failed = None
failed = []
for migration_name, filepath in pending: for migration_name, filepath in pending:
if not run_migration(conn, migration_name, filepath): if not run_migration(conn, migration_name, filepath):
failed.append(migration_name) failed = migration_name
break # Stop on first failure break
conn.close() conn.close()
# Summary
print("\n" + "=" * 60) print("\n" + "=" * 60)
if failed: if failed:
print(f"✗ Migration failed: {failed[0]}") print(f"✗ Abbruch nach: {failed}")
print(" (Bereits erfolgreiche Dateien dieser Session sind committed.)")
print("=" * 60) print("=" * 60)
return 1 return 1
else:
print(f"All migrations executed successfully ({len(pending)} total)") print(f"{len(pending)} Migration(s) angewendet — Schema aktuell.")
print("=" * 60) print("=" * 60)
return 0 return 0
except Exception as e: except Exception as e:
print(f"\nError: {e}") print(f"\nFehler: {e}")
return 1 return 1
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.0" APP_VERSION = "0.8.4"
BUILD_DATE = "2026-04-28" BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260428031" DB_SCHEMA_VERSION = "20260428031"
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -23,6 +23,38 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.4",
"date": "2026-04-27",
"changes": [
"run_migrations: Warteschlange nach numerischem Präfix der Dateien (stabile Reihenfolge auch bei ungleich langen Zahlen wie 9 vs 10)",
],
},
{
"version": "0.8.3",
"date": "2026-04-28",
"changes": [
"Migrationen: Warteschlange aller fehlenden *.sql; idempotent über schema_migrations",
"Ausführung vorzugsweise psql -1 -f (eine Transaktion pro Datei); Fallback psycopg2 + sqlparse.split",
"requirements: sqlparse wenn kein psql im PATH",
],
},
{
"version": "0.8.2",
"date": "2026-04-28",
"changes": [
"main: run_migrations — Exit-Code auswerten; bei Fehler kein API-Start (verhindert Prod ohne Skill-Tabellen wie 022)",
"SKIP_DB_MIGRATE=1 optional für lokale Runs ohne Datenbank",
],
},
{
"version": "0.8.1",
"date": "2026-04-28",
"changes": [
"skills: JSONB keywords per psycopg2.extras.Json (verhindert 500 wenn keywords als Objekt/Array gesendet wird)",
"GET /api/health/ready: DB-Verbindung + Kern-Tabellen + schema_migrations_count (ohne Auth, für Prod-Debugging)",
],
},
{ {
"version": "0.8.0", "version": "0.8.0",
"date": "2026-04-28", "date": "2026-04-28",

View File

@ -1,11 +1,19 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-04-27 **Stand:** 2026-04-28
**App-Version:** 0.7.6 (`backend/version.py`) **App-Version / DB-Schema:** siehe `backend/version.py`
**DB-Schema-Version:** `20260427027` (Migration 027)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**.
### Produktion: `relation … does not exist` (z.B. `skill_main_categories`)
Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_complete`** (und ggf. Folgende) wurden auf dieser Datenbank noch nicht erfolgreich ausgeführt.
- **Automatisch:** Die Dateien `migrations/*.sql` (numerisch sortiert) bilden eine **Warteschlange**. Bereits erfolgreiche Läufe stehen in `schema_migrations` — diese Dateien werden **übersprungen** (idempotent). Pro Datei läuft **eine Transaktion** (im Container vorzugsweise `psql -1 -f`, sonst Fallback `sqlparse` + psycopg2).
- **Fix / manuell:** `docker exec shinkan-api python /app/run_migrations.py` — Exit-Code **0**.
- Aktuelle Builds: Bei fehlgeschlagenem Migrate startet **`main.py`** die API nicht. Lokal ohne DB: **`SKIP_DB_MIGRATE=1`**.
--- ---
## 1. Pflichtlektüre (Kontext & Anforderungen) ## 1. Pflichtlektüre (Kontext & Anforderungen)