fix(seed): prevent duplicate seeding under multi-worker gunicorn
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled

When the beta image runs with --workers > 1 (default: 2), every worker
calls lifespan() in parallel. Each worker sees an empty DB, then
seed_if_empty() runs concurrently, producing 2x (or Nx) demo entries.

Fix: wrap the seed operation in a process-wide FileLock. Even if a
worker loses the race it re-checks the table count inside the critical
section and returns without re-seeding.

Verified by cold-starting a 4-worker container: 8 items (was: 32).
This commit is contained in:
ki
2026-07-21 23:40:57 +02:00
parent 0bca2eaaa2
commit ccf90d3e46
2 changed files with 25 additions and 5 deletions
+24 -5
View File
@@ -4,13 +4,22 @@ from __future__ import annotations
import logging import logging
from datetime import date from datetime import date
from pathlib import Path
from filelock import FileLock
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import crud, models, schemas from app import crud, models, schemas
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# File-Lock gegen Race-Conditions bei mehreren Workern (gunicorn).
# Pro Datenbank-Pfad ein Lock, damit parallele Worker nicht doppelt seeden.
def _lock_path() -> Path:
from app.database import DB_PATH
return DB_PATH.with_suffix(DB_PATH.suffix + ".seed.lock")
DEMO_BOOKS = [ DEMO_BOOKS = [
{ {
"title": "Der Name des Windes", "title": "Der Name des Windes",
@@ -121,10 +130,20 @@ DEMO_SERIES = [
def seed_if_empty(db: Session) -> None: def seed_if_empty(db: Session) -> None:
"""Seedet genau einmal pro DB.
Mehrere gunicorn-Worker können gleichzeitig hochfahren und alle eine leere DB
sehen. Ein File-Lock serialisiert das Seeding prozessübergreifend.
"""
if db.query(models.Media).count() > 0: if db.query(models.Media).count() > 0:
return return
log.info("Leere DB lege Demo-Daten an.") lock = FileLock(str(_lock_path()), timeout=30)
for spec in DEMO_BOOKS: with lock:
crud.create_media(db, schemas.MediaCreate(kind="book", **spec)) # Re-check unter Lock: ein anderer Worker hat womoeglich schon geseedet.
for spec in DEMO_SERIES: if db.query(models.Media).count() > 0:
crud.create_media(db, schemas.MediaCreate(kind="series", **spec)) return
log.info("Leere DB lege Demo-Daten an.")
for spec in DEMO_BOOKS:
crud.create_media(db, schemas.MediaCreate(kind="book", **spec))
for spec in DEMO_SERIES:
crud.create_media(db, schemas.MediaCreate(kind="series", **spec))
+1
View File
@@ -5,3 +5,4 @@ pydantic==2.9.2
httpx==0.27.2 httpx==0.27.2
python-multipart==0.0.10 python-multipart==0.0.10
jinja2==3.1.4 jinja2==3.1.4
filelock==3.16.1