fix(api): os.path.isdir hing im Kernel und toetete die Schleife - harte Zeitgrenze
Ampel / ampel (push) Successful in 28s

Ursache gefunden, nicht geraten. Der neue Diagnose-Endpunkt sagte
`rohdaten.alter_sekunden: null` - die Funktion war NIE EINMAL fertig geworden,
und kein Fehler war gemeldet. Der Blick auf die Threads des API-Prozesses zeigte
zwei im Zustand **D** (uninterruptible sleep, im Kernel blockiert):

  tid=1600034 name=uvicorn state=D
  tid=1600037 name=uvicorn state=D

Der Mechanismus: Die Schleife startete ihren ersten Durchlauf, waehrend Rippy
beim Container-Start die CIFS-Freigabe neu einhaengte. Ihr `os.path.isdir` blieb
im Kernel stecken, `asyncio.to_thread` kam nie zurueck, die Schleife erreichte
ihr `sleep` nie - und war damit fuer immer tot. Sichtbar war nur, dass
can_retry dauerhaft false blieb.

Ein Timeout um den Aufruf haette nichts geholfen: Ein im Kernel haengender
Thread laesst sich aus Python nicht abbrechen, jeder Versuch haette einen
weiteren Thread verbrannt, bis der Pool leer ist.

Ein Kind-PROZESS laesst sich abbrechen. Geprueft wird jetzt mit
`timeout 4 ls -d <pfad>` - dasselbe Werkzeug, das mounts.ist_erreichbar seit dem
24.07.2026 fuer genau dieses Problem benutzt (dort fuer den toten NAS-Mount).
Laeuft es in die Zeitgrenze, gilt das Verzeichnis als "nicht da": Ein Ort, den
man nicht in Sekunden ansehen kann, ist fuer einen Rip ohnehin unbrauchbar.

os.listdir bleibt fuer /app/media selbst - das ist ein lokales Verzeichnis, die
Freigaben sind Unterordner davon. Und os.path.isdir bleibt fuer den Rueckfall
auf /app/temp/raw: ein Docker-Volume, dort kann nichts haengen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 13:28:02 +02:00
parent 0d8426d353
commit 2554c2633b
3 changed files with 140 additions and 10 deletions
+20 -8
View File
@@ -6,6 +6,7 @@ from typing import List, Optional, Dict
import asyncio import asyncio
import json import json
import os import os
import posixpath
import shutil import shutil
import time import time
import uuid import uuid
@@ -344,13 +345,16 @@ async def root():
def _rohdaten_suchen(job_id: str, work_dir: str) -> list: def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
"""Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py) """Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py)
Kann LANGSAM sein: Ein schlafendes NAS lässt `os.path.isdir` bis zum Geprüft wird mit `rohdaten.verzeichnis_da` und NICHT mit os.path.isdir:
CIFS-Timeout hängen — auf dieser VM gemessene 10 Sekunden, während der Mount Letzteres hing am 26.07.2026 unbegrenzt im Kernel, während Rippy die
nach einem Container-Neustart hochkam. Deshalb nur dort direkt aufrufen, wo CIFS-Freigabe neu einhängte — und nahm die Hintergrund-Schleife dauerhaft
ein Mensch auf genau diese Antwort wartet (/rohdaten, retry-transcode). mit. Ausführliche Begründung dort.
Für die Job-Liste gibt es den Vorrat unten.
Bleibt trotzdem der langsame Weg (mehrere Kind-Prozesse, im schlechtesten
Fall je vier Sekunden). Für die Job-Liste, die das Dashboard alle vier
Sekunden abfragt, gibt es deshalb den Vorrat unten.
""" """
return rohdaten.suche(job_id, work_dir, os.listdir, os.path.isdir) return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in # Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
@@ -410,7 +414,9 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
vorrat = _ROHDATEN["treffer"] vorrat = _ROHDATEN["treffer"]
if job["id"] in vorrat: if job["id"] in vorrat:
return bool(vorrat[job["id"]]) return bool(vorrat[job["id"]])
return os.path.isdir(os.path.join(rohdaten.RAW_STANDARD, job["id"])) # Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
@app.get("/jobs", response_model=List[Job]) @app.get("/jobs", response_model=List[Job])
@@ -443,7 +449,13 @@ async def get_jobs():
def _rohdaten_groesse(pfade: list) -> tuple: def _rohdaten_groesse(pfade: list) -> tuple:
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).""" """(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
Hier bleibt os.* stehen: Diese Funktion wird nur aufgerufen, NACHDEM
`verzeichnis_da` den Pfad innerhalb von Sekunden bestätigt hat — der Mount
antwortet also. Und sie läuft nur, wenn ein Mensch auf die Zahl wartet
(/rohdaten, Löschen), nie in der Hintergrund-Schleife.
"""
return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize) return rohdaten.groesse(pfade, os.listdir, os.path.isfile, os.path.getsize)
+50 -2
View File
@@ -39,11 +39,15 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
""" """
import posixpath import posixpath
import subprocess
# Container-Standard für Roh-Rips (RAW_DIR im Worker). # Container-Standard für Roh-Rips (RAW_DIR im Worker).
RAW_STANDARD = "/app/temp/raw" RAW_STANDARD = "/app/temp/raw"
MEDIA_ROOT = "/app/media" MEDIA_ROOT = "/app/media"
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
PRUEF_TIMEOUT_SEKUNDEN = 4
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list: def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure). """Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
@@ -76,12 +80,56 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
return eindeutig return eindeutig
def verzeichnis_da(pfad: str, laufen=None) -> bool:
"""Gibt es dieses Verzeichnis? — mit HARTER Zeitgrenze.
## Warum nicht os.path.isdir
Weil es an einem Netz-Mount unbegrenzt hängen kann, und zwar im Kernel
(Prozess-Zustand D, „uninterruptible sleep"). Genau das ist am 26.07.2026
passiert: Die Hintergrund-Schleife startete ihren ersten Durchlauf, während
Rippy die CIFS-Freigabe nach einem Container-Neustart neu einhängte. Ihr
`os.path.isdir` blieb stecken, `asyncio.to_thread` kam nie zurück, die
Schleife erreichte ihr `sleep` nie — und war damit für immer tot. Sichtbar
war nur, dass `can_retry` dauerhaft `false` blieb; zwei Threads standen im
Zustand D.
Ein Timeout um den Aufruf hätte nichts geholfen: Ein im Kernel hängender
Thread lässt sich aus Python nicht abbrechen, jeder Versuch hätte einen
weiteren Thread verbrannt, bis der Pool leer ist.
Ein Kind-PROZESS lässt sich abbrechen. Deshalb `timeout N ls -d <pfad>` —
dasselbe Werkzeug, das `mounts.ist_erreichbar` seit dem 24.07.2026 für
genau dieses Problem benutzt (dort für den toten NAS-Mount). Läuft es in
die Zeitgrenze, gilt das Verzeichnis als „nicht da": Ein Ort, den man nicht
innerhalb von Sekunden ansehen kann, ist für einen Rip ohnehin unbrauchbar.
`laufen` ist einspritzbar, damit das ohne echte Prozesse testbar bleibt.
"""
if not pfad:
return False
starten = laufen or subprocess.run
try:
ergebnis = starten(
["timeout", str(PRUEF_TIMEOUT_SEKUNDEN), "ls", "-d", pfad],
capture_output=True,
timeout=PRUEF_TIMEOUT_SEKUNDEN + 2,
)
except (OSError, subprocess.TimeoutExpired):
return False
return ergebnis.returncode == 0
def suche(job_id: str, work_dir: str, listdir, isdir) -> list: def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
"""Die Orte, an denen wirklich etwas liegt. """Die Orte, an denen wirklich etwas liegt.
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche `listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
ohne Dateisystem prüfbar, und ein toter CIFS-Mount kann hier keinen ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und
Import-Zyklus verursachen. NICHT os.path.isdir: Die Kandidaten liegen unter /app/media, und dort kann
ein Netz-Mount unbegrenzt hängen (Begründung bei verzeichnis_da).
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
""" """
try: try:
unterordner = sorted(listdir(MEDIA_ROOT)) unterordner = sorted(listdir(MEDIA_ROOT))
+70
View File
@@ -109,3 +109,73 @@ def test_groesse_ueberspringt_unlesbares():
bytes_gesamt, anzahl = rohdaten.groesse( bytes_gesamt, anzahl = rohdaten.groesse(
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt) ["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
assert (bytes_gesamt, anzahl) == (0, 0) assert (bytes_gesamt, anzahl) == (0, 0)
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
#
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
class _Lauf:
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
def __init__(self, rc=0, wirf=None):
self.rc, self.wirf, self.cmd = rc, wirf, None
def __call__(self, cmd, **kwargs):
self.cmd = cmd
if self.wirf:
raise self.wirf
class E:
returncode = self.rc
return E()
def test_verzeichnis_da_nutzt_timeout_und_ls():
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
lauf = _Lauf(rc=0)
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
assert lauf.cmd[0] == "timeout"
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
def test_verzeichnis_da_nicht_vorhanden():
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
kann, ist fuer einen Rip ohnehin unbrauchbar."""
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
import subprocess as sp
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
assert rohdaten.verzeichnis_da(
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
def laufen(cmd, **kwargs):
pfad = cmd[-1]
class E:
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
return E()
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
)
assert gefunden == [f"/app/media/rippy/{JOB}"]