diff --git a/docker/api/main.py b/docker/api/main.py index 789b610..08ec943 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -6,6 +6,7 @@ from typing import List, Optional, Dict import asyncio import json import os +import posixpath import shutil import time import uuid @@ -344,13 +345,16 @@ async def root(): def _rohdaten_suchen(job_id: str, work_dir: str) -> list: """Wo liegen die Roh-MKVs dieses Jobs? (Details in rohdaten.py) - Kann LANGSAM sein: Ein schlafendes NAS lässt `os.path.isdir` bis zum - CIFS-Timeout hängen — auf dieser VM gemessene 10 Sekunden, während der Mount - nach einem Container-Neustart hochkam. Deshalb nur dort direkt aufrufen, wo - ein Mensch auf genau diese Antwort wartet (/rohdaten, retry-transcode). - Für die Job-Liste gibt es den Vorrat unten. + Geprüft wird mit `rohdaten.verzeichnis_da` und NICHT mit os.path.isdir: + Letzteres hing am 26.07.2026 unbegrenzt im Kernel, während Rippy die + CIFS-Freigabe neu einhängte — und nahm die Hintergrund-Schleife dauerhaft + mit. Ausführliche Begründung dort. + + 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 @@ -410,7 +414,9 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool: vorrat = _ROHDATEN["treffer"] if job["id"] in vorrat: 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]) @@ -443,7 +449,13 @@ async def get_jobs(): 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) diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py index 7a6c854..1db87f1 100644 --- a/docker/api/rohdaten.py +++ b/docker/api/rohdaten.py @@ -39,11 +39,15 @@ Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`. """ import posixpath +import subprocess # Container-Standard für Roh-Rips (RAW_DIR im Worker). RAW_STANDARD = "/app/temp/raw" 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: """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 +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 ` — + 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: """Die Orte, an denen wirklich etwas liegt. `listdir` und `isdir` werden übergeben statt importiert — so ist die Suche - ohne Dateisystem prüfbar, und ein toter CIFS-Mount kann hier keinen - Import-Zyklus verursachen. + ohne Dateisystem prüfbar. Für `isdir` gehört `verzeichnis_da` eingesetzt und + 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: unterordner = sorted(listdir(MEDIA_ROOT)) diff --git a/docker/api/test_rohdaten.py b/docker/api/test_rohdaten.py index d8442f5..47245fe 100644 --- a/docker/api/test_rohdaten.py +++ b/docker/api/test_rohdaten.py @@ -109,3 +109,73 @@ def test_groesse_ueberspringt_unlesbares(): bytes_gesamt, anzahl = rohdaten.groesse( ["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt) 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}"]