From ccf90d3e46a5478570203b2b48a7051872e8e261 Mon Sep 17 00:00:00 2001 From: ki Date: Tue, 21 Jul 2026 23:40:57 +0200 Subject: [PATCH] fix(seed): prevent duplicate seeding under multi-worker gunicorn 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). --- app/seed.py | 29 ++++++++++++++++++++++++----- requirements.txt | 1 + 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/seed.py b/app/seed.py index cc762bf..da2193d 100644 --- a/app/seed.py +++ b/app/seed.py @@ -4,13 +4,22 @@ from __future__ import annotations import logging from datetime import date +from pathlib import Path +from filelock import FileLock from sqlalchemy.orm import Session from app import crud, models, schemas 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 = [ { "title": "Der Name des Windes", @@ -121,10 +130,20 @@ DEMO_SERIES = [ 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: 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)) + lock = FileLock(str(_lock_path()), timeout=30) + with lock: + # Re-check unter Lock: ein anderer Worker hat womoeglich schon geseedet. + if db.query(models.Media).count() > 0: + 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)) diff --git a/requirements.txt b/requirements.txt index 59ca07a..c45c28d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pydantic==2.9.2 httpx==0.27.2 python-multipart==0.0.10 jinja2==3.1.4 +filelock==3.16.1