MediaHub: Container stürzt beim Start ab (Wettlauf der gunicorn-Worker um db.json.tmp)

Hallo zusammen,

erst mal vielen Dank für den MediaHub! Da ich 3 Espuinos mit identischen Medien haben, habe ich schon lange auf sowas gehofft. Da ich etwas ähnliches noch nie gemacht habe, habe ich mich von Claude an der Hand nehmen lassen, was erstaunlich gut funktioniert hat.

Dabei bin ich auf das Problem gestoßen, dass der Docker immer wieder unerwartet beendet wurde und neu gestartet ist.

Mit Claude konnte ich einen Workaround entwickeln. Aber ich wollte den Fehler zumindest melden, damit jemand mit mehr Ahnung vom Thema vielleicht mal sehen kann ob da was dran ist.

Ich betreibe den Docker auf einem Synology NAS (Container Manager, docker compose, Dockerfile unverändert, 2 gunicorn-Worker).

Problem
Der Container bricht beim Start ab und startet dann neu:

File „/app/app.py“, line 44, in
store = store_lib.Store(DATA_DIR)
File „/app/store.py“, line 69, in _write
os.replace(tmp_path, self.db_path)
FileNotFoundError: [Errno 2] No such file or directory: ‚/data/db.json.tmp‘ → ‚/data/db.json‘
[ERROR] Reason: Worker failed to boot.

Wann tritt es auf?

  • Beim allerersten Start (noch keine db.json im data-Ordner).
  • Später wieder, nachdem ich rund 190 Karten für 3 Geräte angelegt hatte (db.json ca. 2 MB). Das Schreiben dauert dann länger, der Fehler tritt häufiger auf.

Ursache (Vermutung aus dem Code)
Store() wird beim Import in jedem Worker ausgeführt. _migrate_legacy_cards() ruft _mutate() auf, und das schreibt immer, auch wenn nichts zu migrieren ist. Beide Worker schreiben in dieselbe Zwischendatei db.json.tmp: Worker A benennt sie per os.replace() um, Worker B findet sie dann nicht mehr. Das _lock schützt nur innerhalb eines Prozesses, nicht zwischen den Workern. Dasselbe kann theoretisch auch im laufenden Betrieb passieren, wenn zwei Anfragen gleichzeitig bei verschiedenen Workern landen.

Nachgestellt (von Claude)
In einem Test mit sechs parallel schreibenden Prozessen (je 300 Schreibvorgänge) trat der Fehler mit dem Originalcode in jedem Prozess mehrfach auf, einmal auch mit exakt dem Absturz oben. Mit einem eindeutigen Namen der Zwischendatei gab es keinen Fehler mehr.

Workaround
Bei mir läuft es stabil, wenn gunicorn mit --preload gestartet wird (docker-compose.override.yml mit command: gunicorn … --workers 2 --preload …). Dann läuft die Store-Initialisierung nur einmal im Hauptprozess.

Vorschlag für einen Fix

  1. In store.py, _write(): einen eindeutigen Namen für die Zwischendatei verwenden, z. B.
    tmp_path = f"{self.db_path}.{os.getpid()}.tmp"
  2. _migrate_legacy_cards()/_mutate() nur schreiben lassen, wenn sich tatsächlich etwas geändert hat.
  3. Langfristig eine Dateisperre (z. B. fcntl.flock) statt des reinen Prozess-Locks. Bei zwei Workern kann sonst bei gleichzeitigem Lesen-Ändern-Schreiben eine Änderung verloren gehen.

Nochmal vielen Dank für das super Feature!

Viele Grüße

Jonas

Danke — deine Analyse stimmt in allen drei Punkten. Beide Fixes sind eingecheckt.

Ein Detail macht es noch blöder als die Fehlermeldung vermuten lässt: Nach dem os.replace() von Worker A verschwindet nur der Name db.json.tmp, nicht die Inode. Der offene Deskriptor von Worker B zeigt danach direkt auf db.json — B schreibt seinen Rest also in die gerade veröffentlichte Datenbank hinein, und erst danach scheitert sein eigenes os.replace(). Der Absturz ist das Symptom, nicht der Schaden. Nachgestellt mit zwei fork()-Kindern: Bei gleich langen Datensätzen ist die Änderung des einen Workers still verloren, die Datei bleibt gültiges JSON. Bei unterschiedlich langen bleibt der Schwanz des längeren stehen und man bekommt JSONDecodeError: Extra data. Je größer die Datei, desto größer das Zeitfenster — deshalb fällt es dir erst bei 2 MB auf.

Es betraf übrigens nicht nur den ersten Start: _migrate_legacy_cards() lief in jedem Worker bei jedem Start und schrieb die ganze Datei neu, auch ohne etwas zu migrieren. (Punkt 2).

Umgesetzt ist jetzt alles drei — prozess-eindeutiger Temp-Name und fcntl.flock um das Read-Modify-Write (von @albert0815, der den Fix schon fertig hatte, danke dafür), dazu das Schreiben nur noch bei echter Änderung.

git pull
docker compose up -d --build

Das --build ist nötig, sonst läuft das alte Image weiter. --preload kannst du danach wieder rausnehmen — es hat den Startfehler verhindert, gegen den Wettlauf im Betrieb (dein zweiter Fall mit den 190 Karten) aber nicht geholfen.

Eine Bitte noch: Schau einmal über deine Zuweisungen. Ein stiller Lost Update hinterlässt gültiges JSON, der Datei sieht man also nichts an — auffallen würde nur eine Karte oder ein Gerät, das fehlt. Mit einem Backup des ./data-Volumes von vorher geht der Vergleich schneller.